From 6b03ba9579f2053de9f330acd95d7469129f21be Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Fri, 18 Sep 2026 22:39:56 +0400 Subject: [PATCH 01/40] refactor(sarin): extract cookie header building into cookieHeaderValue --- internal/sarin/request.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/internal/sarin/request.go b/internal/sarin/request.go index ed43501..d013821 100644 --- a/internal/sarin/request.go +++ b/internal/sarin/request.go @@ -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) From 5ca9cda2b9e2ec3c373f39c1ebff08d7c641a904 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 19 Sep 2026 00:14:48 +0400 Subject: [PATCH 02/40] docs(cli): show config-file flag type as []string in help text --- internal/config/cli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/cli.go b/internal/config/cli.go index a94b09c..e540aeb 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -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) From 8b437b487cf2ca7ae314799382234d45611c66e4 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 20 Sep 2026 23:02:51 +0400 Subject: [PATCH 03/40] feat(script): add http() and json to Lua and JS scripts --- internal/config/config.go | 8 +- internal/config/template_validator.go | 2 +- internal/sarin/client.go | 21 +-- internal/sarin/filecache.go | 3 +- internal/sarin/runner.go | 101 ++++++-------- internal/sarin/scripthttp.go | 133 ++++++++++++++++++ internal/sarin/template.go | 4 +- internal/sarin/worker.go | 64 ++++++--- internal/script/chain.go | 10 ++ internal/script/http.go | 112 +++++++++++++++ internal/script/js.go | 42 +++++- internal/script/js_http.go | 192 ++++++++++++++++++++++++++ internal/script/lua.go | 62 ++++----- internal/script/lua_http.go | 139 +++++++++++++++++++ internal/script/lua_json.go | 148 ++++++++++++++++++++ internal/script/script.go | 9 +- internal/types/errors.go | 80 +++++++++++ 17 files changed, 990 insertions(+), 140 deletions(-) create mode 100644 internal/sarin/scripthttp.go create mode 100644 internal/script/http.go create mode 100644 internal/script/js_http.go create mode 100644 internal/script/lua_http.go create mode 100644 internal/script/lua_json.go diff --git a/internal/config/config.go b/internal/config/config.go index 9cef997..dc5252d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/template_validator.go b/internal/config/template_validator.go index ee1deaa..f408d8d 100644 --- a/internal/config/template_validator.go +++ b/internal/config/template_validator.go @@ -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) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index 8854b7b..1973f5e 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -18,7 +18,8 @@ import ( "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 { @@ -306,18 +307,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...) } diff --git a/internal/sarin/filecache.go b/internal/sarin/filecache.go index 5102027..1fb7d00 100644 --- a/internal/sarin/filecache.go +++ b/internal/sarin/filecache.go @@ -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 } diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 178be54..2ede361 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -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) + 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) }) } } diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go new file mode 100644 index 0000000..2b88cdc --- /dev/null +++ b/internal/sarin/scripthttp.go @@ -0,0 +1,133 @@ +package sarin + +import ( + "context" + "crypto/tls" + "net/url" + "time" + + "github.com/valyala/fasthttp" + "go.aykhans.me/sarin/internal/script" + "go.aykhans.me/sarin/internal/types" +) + +// scriptHTTPMaxRedirects is the redirect limit when followRedirects is set. +const scriptHTTPMaxRedirects = 10 + +// 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) ([]*scriptHTTPClient, error) { + if len(proxies) == 0 { + return []*scriptHTTPClient{newScriptHTTPClient(nil, scriptHTTPDefaultTimeout)}, nil + } + + clients := make([]*scriptHTTPClient, 0, len(proxies)) + for _, proxy := range proxies { + dialFunc, err := NewProxyDialFunc(ctx, &proxy, scriptHTTPDefaultTimeout) + if err != nil { + return nil, types.NewProxyDialError(proxy.String(), err) + } + clients = append(clients, newScriptHTTPClient(dialFunc, scriptHTTPDefaultTimeout)) + } + return clients, nil +} + +func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duration) *scriptHTTPClient { + newClient := func(insecure bool) *fasthttp.Client { + return &fasthttp.Client{ + Dial: dialFunc, + TLSConfig: &tls.Config{ + InsecureSkipVerify: insecure, //nolint:gosec + }, + DisableHeaderNamesNormalizing: true, + DisablePathNormalizing: true, + NoDefaultUserAgentHeader: true, + } + } + + return &scriptHTTPClient{ + client: newClient(false), + insecureClient: newClient(true), + defaultTimeout: defaultTimeout, + } +} + +// Do sends the request. With followRedirects 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.FollowRedirects { + err = client.DoRedirects(req, resp, scriptHTTPMaxRedirects) + } 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 +} diff --git a/internal/sarin/template.go b/internal/sarin/template.go index 4992214..c2a3c35 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -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 { @@ -751,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 } diff --git a/internal/sarin/worker.go b/internal/sarin/worker.go index 6cb9531..3b4e444 100644 --- a/internal/sarin/worker.go +++ b/internal/sarin/worker.go @@ -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()) } diff --git a/internal/script/chain.go b/internal/script/chain.go index 934c98c..0067990 100644 --- a/internal/script/chain.go +++ b/internal/script/chain.go @@ -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 { diff --git a/internal/script/http.go b/internal/script/http.go new file mode 100644 index 0000000..a82a221 --- /dev/null +++ b/internal/script/http.go @@ -0,0 +1,112 @@ +package script + +import ( + "net/http" + "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 + FollowRedirects bool +} + +// 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 + +// Option keys accepted by the http function. +const ( + httpOptionMethod = "method" + httpOptionHeaders = "headers" + httpOptionParams = "params" + httpOptionCookies = "cookies" + httpOptionBody = "body" + httpOptionTimeout = "timeout" + httpOptionInsecure = "insecure" + httpOptionFollowRedirects = "followRedirects" +) + +// 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) +} + +// 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 +} diff --git a/internal/script/js.go b/internal/script/js.go index cfaddfe..02bb053 100644 --- a/internal/script/js.go +++ b/internal/script/js.go @@ -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", jsExceptionError(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", jsExceptionError(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,18 @@ 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 } + +// jsExceptionError keeps the thrown value's message without goja's stack frame. +func jsExceptionError(err error) error { + var exception *goja.Exception + if !errors.As(err, &exception) { + return err + } + return errors.New(exception.Value().String()) +} diff --git a/internal/script/js_http.go b/internal/script/js_http.go new file mode 100644 index 0000000..d519102 --- /dev/null +++ b/internal/script/js_http.go @@ -0,0 +1,192 @@ +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, httpOptionFollowRedirects: + flag, ok := value.Export().(bool) + if !ok { + return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value))) + } + + if name == httpOptionInsecure { + req.Insecure = flag + } else { + req.FollowRedirects = flag + } + 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) +} diff --git a/internal/script/lua.go b/internal/script/lua.go index 918792a..e6c2fd1 100644 --- a/internal/script/lua.go +++ b/internal/script/lua.go @@ -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 } diff --git a/internal/script/lua_http.go b/internal/script/lua_http.go new file mode 100644 index 0000000..efd7fc2 --- /dev/null +++ b/internal/script/lua_http.go @@ -0,0 +1,139 @@ +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, httpOptionFollowRedirects: + flag, ok := v.(lua.LBool) + if !ok { + err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String())) + return + } + if name == httpOptionInsecure { + req.Insecure = bool(flag) + } else { + req.FollowRedirects = bool(flag) + } + 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 +} diff --git a/internal/script/lua_json.go b/internal/script/lua_json.go new file mode 100644 index 0000000..bdc2ba2 --- /dev/null +++ b/internal/script/lua_json.go @@ -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 + } +} diff --git a/internal/script/script.go b/internal/script/script.go index a4b94e5..07b3365 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -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 } diff --git a/internal/types/errors.go b/internal/types/errors.go index c55d96c..0ad03b1 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -401,8 +401,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 From 529420ee5a69829009310f1993c05745d0b2843c Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 20 Sep 2026 23:18:21 +0400 Subject: [PATCH 04/40] feat(script): replace followRedirects with maxRedirects http option --- internal/sarin/scripthttp.go | 9 +++---- internal/script/http.go | 49 +++++++++++++++++++++++------------- internal/script/js_http.go | 28 +++++++++++++++++---- internal/script/lua_http.go | 13 ++++++---- 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 2b88cdc..9cdcb4f 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -11,9 +11,6 @@ import ( "go.aykhans.me/sarin/internal/types" ) -// scriptHTTPMaxRedirects is the redirect limit when followRedirects is set. -const scriptHTTPMaxRedirects = 10 - // scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T. const scriptHTTPDefaultTimeout = 30 * time.Second @@ -65,7 +62,7 @@ func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duratio } } -// Do sends the request. With followRedirects the timeout applies to each hop. +// 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) { @@ -116,8 +113,8 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro } req.SetTimeout(timeout) - if r.FollowRedirects { - err = client.DoRedirects(req, resp, scriptHTTPMaxRedirects) + if r.MaxRedirects > 0 { + err = client.DoRedirects(req, resp, r.MaxRedirects) } else { err = client.Do(req, resp) } diff --git a/internal/script/http.go b/internal/script/http.go index a82a221..7dc1b80 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -1,7 +1,9 @@ package script import ( + "math" "net/http" + "strconv" "strings" "time" @@ -10,15 +12,15 @@ import ( // 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 - FollowRedirects bool + 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. @@ -63,14 +65,14 @@ const httpDefaultMethod = http.MethodGet // Option keys accepted by the http function. const ( - httpOptionMethod = "method" - httpOptionHeaders = "headers" - httpOptionParams = "params" - httpOptionCookies = "cookies" - httpOptionBody = "body" - httpOptionTimeout = "timeout" - httpOptionInsecure = "insecure" - httpOptionFollowRedirects = "followRedirects" + 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. @@ -97,6 +99,19 @@ func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) { 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 count < 0 || count != math.Trunc(count) { + return 0, types.NewScriptHTTPOptionError( + httpOptionMaxRedirects, + types.NewScriptTypeError("a whole number of 0 or more", strconv.FormatFloat(count, 'f', -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 diff --git a/internal/script/js_http.go b/internal/script/js_http.go index d519102..7cdce59 100644 --- a/internal/script/js_http.go +++ b/internal/script/js_http.go @@ -100,16 +100,22 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error { if req.Timeout, err = parseHTTPTimeout(timeout); err != nil { return err } - case httpOptionInsecure, httpOptionFollowRedirects: + case httpOptionInsecure: flag, ok := value.Export().(bool) if !ok { return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value))) } - if name == httpOptionInsecure { - req.Insecure = flag - } else { - req.FollowRedirects = flag + 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) @@ -190,3 +196,15 @@ func (e *JsEngine) throw(err error) { } 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 + } +} diff --git a/internal/script/lua_http.go b/internal/script/lua_http.go index efd7fc2..a1e3a14 100644 --- a/internal/script/lua_http.go +++ b/internal/script/lua_http.go @@ -92,17 +92,20 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error { return } req.Timeout, err = parseHTTPTimeout(string(timeout)) - case httpOptionInsecure, httpOptionFollowRedirects: + case httpOptionInsecure: flag, ok := v.(lua.LBool) if !ok { err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String())) return } - if name == httpOptionInsecure { - req.Insecure = bool(flag) - } else { - req.FollowRedirects = bool(flag) + 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) } From a60287b78ba712bdfeab5f6d2109c132ac7a23ce Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 20 Sep 2026 23:30:16 +0400 Subject: [PATCH 05/40] docs: document http() and Lua json in scripts --- docs/configuration.md | 101 ++++++++++++++++++++++++++++ docs/examples.md | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 5258d0f..93c52ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | + +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; +} +``` diff --git a/docs/examples.md b/docs/examples.md index 79029ba..9374240 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1087,3 +1087,154 @@ js: "@./scripts/body.js" ``` + +**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' +``` + +
+YAML equivalent + +```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 +``` + +
+ +**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; + }' +``` From 00226b0eb79efd1fbbaff08eae2d6eafb9acc88e Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 22 Sep 2026 19:29:51 +0400 Subject: [PATCH 06/40] fix: size script HTTP client connection pool to worker count --- internal/sarin/runner.go | 2 +- internal/sarin/scripthttp.go | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 2ede361..921d306 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -274,7 +274,7 @@ func NewSarin( var scriptHTTPClients []*scriptHTTPClient if !scriptChain.IsEmpty() { - scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs) + scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs, workers) if err != nil { return nil, err } diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 9cdcb4f..8202abd 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -26,9 +26,9 @@ 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) ([]*scriptHTTPClient, error) { +func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(nil, scriptHTTPDefaultTimeout)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(nil, scriptHTTPDefaultTimeout, maxConns)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) @@ -37,15 +37,16 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL) ([]*scriptHTTP if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(dialFunc, scriptHTTPDefaultTimeout)) + clients = append(clients, newScriptHTTPClient(dialFunc, scriptHTTPDefaultTimeout, maxConns)) } return clients, nil } -func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duration) *scriptHTTPClient { +func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { newClient := func(insecure bool) *fasthttp.Client { return &fasthttp.Client{ - Dial: dialFunc, + Dial: dialFunc, + MaxConnsPerHost: safeUintToInt(maxConns), TLSConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec }, From d4c7dcc42c9b199d7a6d805ce7b63f730a24f2b5 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 23 Sep 2026 00:45:44 +0400 Subject: [PATCH 07/40] fix: apply per-request timeouts to script HTTP proxy dials --- internal/sarin/client.go | 103 +++++++++++++++++++---------------- internal/sarin/scripthttp.go | 34 ++++++++++-- internal/types/errors.go | 2 + 3 files changed, 86 insertions(+), 53 deletions(-) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index 1973f5e..d64b2b3 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -12,7 +12,6 @@ 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" @@ -95,40 +94,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 @@ -150,21 +154,14 @@ 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) if resolveLocally { @@ -204,12 +201,20 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, }, 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 @@ -223,7 +228,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) @@ -243,13 +248,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 @@ -263,35 +270,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 } } diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 8202abd..cb86aad 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -3,6 +3,7 @@ package sarin import ( "context" "crypto/tls" + "net" "net/url" "time" @@ -28,24 +29,26 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil) // - types.ProxyDialError func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(nil, scriptHTTPDefaultTimeout, maxConns)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) for _, proxy := range proxies { - dialFunc, err := NewProxyDialFunc(ctx, &proxy, scriptHTTPDefaultTimeout) + dial, err := NewProxyDialFuncWithTimeout(ctx, &proxy) if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(dialFunc, scriptHTTPDefaultTimeout, maxConns)) + clients = append(clients, newScriptHTTPClient(dial, scriptHTTPDefaultTimeout, maxConns)) } return clients, nil } -func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { +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{ - Dial: dialFunc, + DialTimeout: dialWithDeadline(dial), MaxConnsPerHost: safeUintToInt(maxConns), TLSConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec @@ -63,6 +66,27 @@ func newScriptHTTPClient(dialFunc fasthttp.DialFunc, defaultTimeout time.Duratio } } +// 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 diff --git a/internal/types/errors.go b/internal/types/errors.go index 0ad03b1..2b059f0 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -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 } From 9b0e7c4304ca3feb6f6bcfedeed915e39f4f50c9 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 23 Sep 2026 01:03:42 +0400 Subject: [PATCH 08/40] fix: dial IPv6 hosts by enabling dual-stack and trying every resolved address --- internal/sarin/client.go | 82 ++++++++++++++++++++++++------------ internal/sarin/scripthttp.go | 2 +- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index d64b2b3..0ecc32a 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -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 }, @@ -164,43 +165,68 @@ func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally 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. // diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index cb86aad..2143d4d 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -29,7 +29,7 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil) // - types.ProxyDialError func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) From 878ca6dfbd745434a2736b378f7b0eda1f93684e Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 24 Sep 2026 19:08:58 +0400 Subject: [PATCH 09/40] fix(script): bound maxRedirects to 100 and reject out of range values --- docs/configuration.md | 2 +- internal/script/http.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 93c52ad..bee01f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -582,7 +582,7 @@ http(url, opts) | `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 | +| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 | Unknown options and values of the wrong type raise an error. diff --git a/internal/script/http.go b/internal/script/http.go index 7dc1b80..0b80b1f 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -63,6 +63,9 @@ type HTTPDoer interface { // 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" @@ -103,10 +106,13 @@ func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) { // It can return the following errors: // - types.ScriptHTTPOptionError func parseHTTPMaxRedirects(count float64) (int, error) { - if count < 0 || count != math.Trunc(count) { + if math.IsNaN(count) || count < 0 || count > httpMaxRedirectsLimit || count != math.Trunc(count) { return 0, types.NewScriptHTTPOptionError( httpOptionMaxRedirects, - types.NewScriptTypeError("a whole number of 0 or more", strconv.FormatFloat(count, 'f', -1, 64)), + types.NewScriptTypeError( + "a whole number between 0 and "+strconv.Itoa(httpMaxRedirectsLimit), + strconv.FormatFloat(count, 'g', -1, 64), + ), ) } return int(count), nil From 5d91141f80655ddcb8e0b14b3af94f68a40cc0e5 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 24 Sep 2026 22:34:59 +0400 Subject: [PATCH 10/40] fix(script): stringify thrown JavaScript values through Runtime.Try --- internal/script/js.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/script/js.go b/internal/script/js.go index 02bb053..721570a 100644 --- a/internal/script/js.go +++ b/internal/script/js.go @@ -43,7 +43,7 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) { // Execute the script to define the transform function _, err := vm.RunString(scriptContent) if err != nil { - return nil, types.NewScriptExecutionError("JavaScript", jsExceptionError(err)) + return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err)) } // Get the transform function @@ -80,7 +80,7 @@ func (e *JsEngine) Transform(req *RequestData) error { // Call transform(req) result, err := e.transform(goja.Undefined(), reqObj) if err != nil { - return types.NewScriptExecutionError("JavaScript", jsExceptionError(err)) + return types.NewScriptExecutionError("JavaScript", e.exceptionError(err)) } // Update RequestData from the returned object @@ -216,11 +216,18 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string return result } -// jsExceptionError keeps the thrown value's message without goja's stack frame. -func jsExceptionError(err error) error { +// 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 } - return errors.New(exception.Value().String()) + + var message string + if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil { + message = "unprintable thrown value" + } + return errors.New(message) } From a30c8629360919595b1169dc7fd3197c8ac0c8c1 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Fri, 25 Sep 2026 22:34:20 +0400 Subject: [PATCH 11/40] fix(script): bind script HTTP requests to the run context --- cmd/cli/main.go | 2 +- internal/sarin/runner.go | 32 +++++++++++++++++++------------- internal/sarin/scripthttp.go | 12 +++++++++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/cmd/cli/main.go b/cmd/cli/main.go index c492843..4b05082 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -75,7 +75,7 @@ func main() { }), ) - srn.Start(ctx, stopCtrl) + srn.Start(stopCtrl) switch *combinedConfig.Output { case config.ConfigOutputTypeNone: diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 921d306..cfaafd6 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -199,6 +199,10 @@ type sarin struct { logError bool logFile string + // jobsCtx ends the run, either when the duration expires or on Ctrl+C. + jobsCtx context.Context //nolint:containedctx + jobsCancel context.CancelFunc + hostClients []*fasthttp.HostClient scriptHTTPClients []*scriptHTTPClient responses *SarinResponseData @@ -272,15 +276,20 @@ func NewSarin( scriptChain := script.NewChain(luaSources, jsSources) + jobsCtx, jobsCancel := context.WithCancel(ctx) + var scriptHTTPClients []*scriptHTTPClient if !scriptChain.IsEmpty() { - scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs, workers) + scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers) if err != nil { + jobsCancel() return nil, err } } srn := &sarin{ + jobsCtx: jobsCtx, + jobsCancel: jobsCancel, workers: workers, requestURL: requestURL, methods: methods, @@ -316,9 +325,7 @@ func (s sarin) GetResponses() *SarinResponseData { return s.responses } -func (s sarin) Start(ctx context.Context, stopCtrl *StopController) { - jobsCtx, jobsCancel := context.WithCancel(ctx) - +func (s sarin) Start(stopCtrl *StopController) { var workersWG sync.WaitGroup jobsCh := make(chan struct{}, max(s.workers, 1)) @@ -382,15 +389,14 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) { 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 go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar) } // Setup duration-based cancellation - s.setupDurationTimeout(ctx, jobsCancel) + s.setupDurationTimeout() // Distribute jobs to workers. // This blocks until all jobs are sent or the context is canceled. - s.sendJobs(jobsCtx, jobsCh) + s.sendJobs(jobsCh) // Close the jobs channel so workers stop after completing their current job close(jobsCh) @@ -455,31 +461,31 @@ func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *a } } -func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) { +func (s sarin) setupDurationTimeout() { if s.totalDuration != nil { go func() { timer := time.NewTimer(*s.totalDuration) defer timer.Stop() select { case <-timer.C: - cancel() - case <-ctx.Done(): + s.jobsCancel() + case <-s.jobsCtx.Done(): // Context cancelled, cleanup } }() } } -func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) { +func (s sarin) sendJobs(jobs chan<- struct{}) { if s.totalRequests != nil && *s.totalRequests > 0 { for range *s.totalRequests { - if ctx.Err() != nil { + if s.jobsCtx.Err() != nil { break } jobs <- struct{}{} } } else { - for ctx.Err() == nil { + for s.jobsCtx.Err() == nil { jobs <- struct{}{} } } diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 2143d4d..de1c567 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -17,6 +17,7 @@ const scriptHTTPDefaultTimeout = 30 * time.Second // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { + ctx context.Context //nolint:containedctx client *fasthttp.Client insecureClient *fasthttp.Client defaultTimeout time.Duration @@ -29,7 +30,7 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil) // - 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 + return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) @@ -38,12 +39,12 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(dial, scriptHTTPDefaultTimeout, maxConns)) + clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns)) } return clients, nil } -func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { +func newScriptHTTPClient(ctx context.Context, 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. @@ -60,6 +61,7 @@ func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time. } return &scriptHTTPClient{ + ctx: ctx, client: newClient(false), insecureClient: newClient(true), defaultTimeout: defaultTimeout, @@ -91,6 +93,10 @@ func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTi // It can return the following errors: // - types.ScriptHTTPRequestError func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) { + if err := c.ctx.Err(); err != nil { + return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) + } + parsedURL, err := url.Parse(r.URL) if err != nil { return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) From c32949c8e0e84476e5c308f8398954e6339ab7be Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 26 Sep 2026 01:17:49 +0400 Subject: [PATCH 12/40] fix(script): convert non-string header, param and cookie values to text --- docs/configuration.md | 4 ++-- internal/script/js.go | 36 +++++++++++++++++++++++++----------- internal/script/lua.go | 35 ++++++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index bee01f9..d8df4f6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -456,7 +456,7 @@ function transform(req) end ``` -> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). +> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped. > **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts). @@ -523,7 +523,7 @@ function transform(req) { } ``` -> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). +> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped. > **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts). diff --git a/internal/script/js.go b/internal/script/js.go index 721570a..734b225 100644 --- a/internal/script/js.go +++ b/internal/script/js.go @@ -2,6 +2,7 @@ package script import ( "errors" + "strconv" "github.com/dop251/goja" "go.aykhans.me/sarin/internal/types" @@ -195,22 +196,23 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string result := make(map[string][]string) for _, key := range obj.Keys() { v := obj.Get(key) - if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { - continue - } // Check if it's an array - if arr, ok := v.Export().([]any); ok { - var values []string - for _, item := range arr { - if s, ok := item.(string); ok { - values = append(values, s) + if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" { + length := int(arr.Get("length").ToInteger()) + values := make([]string, 0, length) + for i := range length { + if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok { + values = append(values, text) } } result[key] = values - } else { - // Single value, wrap it in a slice - result[key] = []string{v.String()} + continue + } + + // Single value, wrap it in a slice + if text, ok := jsStringValue(v); ok { + result[key] = []string{text} } } return result @@ -231,3 +233,15 @@ func (e *JsEngine) exceptionError(err error) error { } return errors.New(message) } + +// jsStringValue renders a JavaScript primitive the way the script would see it. +// Objects, arrays and functions have no useful text form, so they are skipped. +func jsStringValue(v goja.Value) (string, bool) { + if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { + return "", false + } + if _, isObject := v.(*goja.Object); isObject { + return "", false + } + return v.String(), true +} diff --git a/internal/script/lua.go b/internal/script/lua.go index e6c2fd1..9956d44 100644 --- a/internal/script/lua.go +++ b/internal/script/lua.go @@ -168,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string { } key := string(k.(lua.LString)) - switch v.Type() { - case lua.LTString: - // Single string value - result[key] = []string{string(v.(lua.LString))} - case lua.LTTable: - // Array of strings - var values []string - v.(*lua.LTable).ForEach(func(_, item lua.LValue) { - if item.Type() == lua.LTString { - values = append(values, string(item.(lua.LString))) + if v.Type() == lua.LTTable { + // Array of values, keys outside 1..n are skipped + arr := v.(*lua.LTable) + length := arr.Len() + values := make([]string, 0, length) + for i := 1; i <= length; i++ { + if text, ok := luaScalarString(arr.RawGetInt(i)); ok { + values = append(values, text) } - }) + } result[key] = values + return + } + + if text, ok := luaScalarString(v); ok { + result[key] = []string{text} } }) return result } + +// luaScalarString renders strings, numbers and booleans as text. +func luaScalarString(v lua.LValue) (string, bool) { + switch v.Type() { + case lua.LTString, lua.LTNumber, lua.LTBool: + return v.String(), true + default: + return "", false + } +} From 8da05bb10cb0b1f9597a57e39ada3346364716a2 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 27 Sep 2026 01:03:04 +0400 Subject: [PATCH 13/40] fix(stats): key generation failures by source and bound the key count --- internal/sarin/response.go | 13 +++++++++++++ internal/sarin/worker.go | 22 ++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/sarin/response.go b/internal/sarin/response.go index fef21a3..f5d8581 100644 --- a/internal/sarin/response.go +++ b/internal/sarin/response.go @@ -17,6 +17,14 @@ import ( const DefaultResponseDurationAccuracy uint32 = 1 const DefaultResponseColumnMaxWidth = 50 +// maxResponseKeys bounds the distinct response keys, so error messages carrying +// per-request text cannot grow the map without limit. It stays above the number of +// HTTP status codes, so real ones are never merged. +const maxResponseKeys = 1000 + +// otherResponseKey collects everything that arrives after the key limit is reached. +const otherResponseKey = "other" + // Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings. type Duration time.Duration @@ -95,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio defer data.Unlock() response, ok := data.Responses[responseKey] + if !ok && len(data.Responses) >= maxResponseKeys { + responseKey = otherResponseKey + response, ok = data.Responses[responseKey] + } + if !ok { data.Responses[responseKey] = &Response{ durations: map[time.Duration]uint64{ diff --git a/internal/sarin/worker.go b/internal/sarin/worker.go index 3b4e444..2889c73 100644 --- a/internal/sarin/worker.go +++ b/internal/sarin/worker.go @@ -1,12 +1,14 @@ package sarin import ( + "errors" "strconv" "sync/atomic" "time" "github.com/valyala/fasthttp" "go.aykhans.me/sarin/internal/script" + "go.aykhans.me/sarin/internal/types" ) const dryRunResponseKey = "dry-run" @@ -29,6 +31,16 @@ func statusCodeToString(code int) string { return strconv.Itoa(code) } +// requestErrorKey keys a generation failure by its source instead of its message. +// A script picks its own URLs and error texts, so the message would mint a new +// stats entry per request. The full message still goes to the runtime log. +func requestErrorKey(err error) string { + if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok { + return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error" + } + return "request generation error" +} + func (s sarin) Worker( jobs <-chan struct{}, counter *atomic.Uint64, @@ -112,7 +124,7 @@ func (s sarin) workerStatsWithDynamic( clientIndex := nextClientIndex() bindScriptProxy(clientIndex) if err := requestGenerator(req); err != nil { - s.responses.Add(err.Error(), 0) + s.responses.Add(requestErrorKey(err), 0) sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) continue @@ -144,8 +156,9 @@ func (s sarin) workerStatsWithStatic( ) { if err := requestGenerator(req); err != nil { // Static request generation failed, so record all jobs as errors + errorKey := requestErrorKey(err) for range jobs { - s.responses.Add(err.Error(), 0) + s.responses.Add(errorKey, 0) sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) } @@ -241,7 +254,7 @@ func (s sarin) workerDryRunStatsWithDynamic( bindScriptProxy(nextClientIndex()) startTime := time.Now() if err := requestGenerator(req); err != nil { - s.responses.Add(err.Error(), time.Since(startTime)) + s.responses.Add(requestErrorKey(err), time.Since(startTime)) sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) continue @@ -260,8 +273,9 @@ func (s sarin) workerDryRunStatsWithStatic( ) { if err := requestGenerator(req); err != nil { // Static request generation failed, so record all jobs as errors + errorKey := requestErrorKey(err) for range jobs { - s.responses.Add(err.Error(), 0) + s.responses.Add(errorKey, 0) sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) } From 412a02fea3c04bd3eb80aadf71764b3439df86da Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 27 Sep 2026 01:05:07 +0400 Subject: [PATCH 14/40] chore: bump golangci-lint to v2.14.0 --- .github/workflows/lint.yaml | 2 +- Taskfile.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e80bf5b..bc940d2 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -24,4 +24,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.13.2 + version: v2.14.0 diff --git a/Taskfile.yaml b/Taskfile.yaml index 97626b4..2a3b742 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,7 +5,7 @@ vars: BIN_DIR: ./bin GO_VERSION: sh: sed -n 's/^go //p' go.mod - GOLANGCI_LINT_VERSION: v2.13.2 + GOLANGCI_LINT_VERSION: v2.14.0 GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}" tasks: From f1d91e3d77534b05a58964fe8d618db637192434 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Mon, 28 Sep 2026 22:49:39 +0400 Subject: [PATCH 15/40] feat: limit response body sizes fasthttp allocates the body from the declared Content-Length before reading it, so a server could exhaust memory by declaring a size it never sends. The new max response body config bounds every response, defaults to 10MiB and accepts sizes like 10MiB or 2MB. Scripts get the same as a maxBodySize option on http(). --- README.md | 2 +- cmd/cli/main.go | 3 +- docs/configuration.md | 67 +++++++++++-------- internal/config/bytesize.go | 123 +++++++++++++++++++++++++++++++++++ internal/config/cli.go | 80 +++++++++++++---------- internal/config/config.go | 107 ++++++++++++++++++------------ internal/config/env.go | 16 +++++ internal/config/file.go | 48 +++++++------- internal/sarin/client.go | 11 +--- internal/sarin/helpers.go | 15 +++++ internal/sarin/runner.go | 5 +- internal/sarin/scripthttp.go | 58 +++++++++++++++-- internal/sarin/worker.go | 20 +++++- internal/script/http.go | 21 ++++++ internal/script/js_http.go | 10 +++ internal/script/lua_http.go | 7 ++ internal/types/errors.go | 6 ++ 17 files changed, 452 insertions(+), 147 deletions(-) create mode 100644 internal/config/bytesize.go diff --git a/README.md b/README.md index 5f21d72..9d473a6 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ 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/release +nix profile add github:aykhans/sarin/release ``` Or add it to your own flake via the overlay: diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 4b05082..0f4390d 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -51,7 +51,8 @@ func main() { ctx, combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout, *combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration, - *combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers, + *combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody, + combinedConfig.Params, combinedConfig.Headers, combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values, *combinedConfig.Output != config.ConfigOutputTypeNone, *combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile, diff --git a/docs/configuration.md b/docs/configuration.md index d8df4f6..8020761 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,32 +14,33 @@ Use `-s` or `--show-config` to see the final merged configuration before sending > **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values. -| Name | YAML | CLI | ENV | Default | Description | -| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- | -| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | -| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | -| [Show Config](#show-config) | `showConfig`
(boolean) | `-show-config` / `-s`
(boolean) | `SARIN_SHOW_CONFIG`
(boolean) | `false` | Show merged configuration | -| [Config File](#config-file) | `configFile`
(string / []string) | `-config-file` / `-f`
(string / []string) | `SARIN_CONFIG_FILE`
(string) | - | Path to config file(s) | -| [URL](#url) | `url`
(string) | `-url` / `-U`
(string) | `SARIN_URL`
(string) | - | Target URL (HTTP/HTTPS) | -| [Method](#method) | `method`
(string / []string) | `-method` / `-M`
(string / []string) | `SARIN_METHOD`
(string) | `GET` | HTTP method(s) | -| [Timeout](#timeout) | `timeout`
(duration) | `-timeout` / `-T`
(duration) | `SARIN_TIMEOUT`
(duration) | `10s` | Request timeout | -| [Concurrency](#concurrency) | `concurrency`
(number) | `-concurrency` / `-c`
(number) | `SARIN_CONCURRENCY`
(number) | `1` | Number of concurrent workers | -| [Requests](#requests) | `requests`
(number) | `-requests` / `-r`
(number) | `SARIN_REQUESTS`
(number) | - | Total requests to send | -| [Duration](#duration) | `duration`
(duration) | `-duration` / `-d`
(duration) | `SARIN_DURATION`
(duration) | - | Test duration | -| [Log Level](#log-level) | `logLevel`
(string) | `-log-level` / `-l`
(string) | `SARIN_LOG_LEVEL`
(string) | `error` | Runtime log levels to emit | -| [Log File](#log-file) | `logFile`
(string) | `-log-file` / `-w`
(string) | `SARIN_LOG_FILE`
(string) | - | Write runtime logs to a file | -| [Progress](#progress) | `progress`
(string) | `-progress` / `-p`
(string) | `SARIN_PROGRESS`
(string) | `bar` | Progress display (bar/none) | -| [Output](#output) | `output`
(string) | `-output` / `-o`
(string) | `SARIN_OUTPUT`
(string) | `table` | Output format for stats | -| [Dry Run](#dry-run) | `dryRun`
(boolean) | `-dry-run` / `-z`
(boolean) | `SARIN_DRY_RUN`
(boolean) | `false` | Generate without sending | -| [Insecure](#insecure) | `insecure`
(boolean) | `-insecure` / `-I`
(boolean) | `SARIN_INSECURE`
(boolean) | `false` | Skip TLS verification | -| [Body](#body) | `body`
(string / []string) | `-body` / `-B`
(string / []string) | `SARIN_BODY`
(string) | - | Request body | -| [Params](#params) | `params`
(object) | `-param` / `-P`
(string / []string) | `SARIN_PARAM`
(string) | - | URL query parameters | -| [Headers](#headers) | `headers`
(object) | `-header` / `-H`
(string / []string) | `SARIN_HEADER`
(string) | - | HTTP headers | -| [Cookies](#cookies) | `cookies`
(object) | `-cookie` / `-C`
(string / []string) | `SARIN_COOKIE`
(string) | - | HTTP cookies | -| [Proxy](#proxy) | `proxy`
(string / []string) | `-proxy` / `-X`
(string / []string) | `SARIN_PROXY`
(string) | - | Proxy URL(s) | -| [Values](#values) | `values`
(string / []string) | `-values` / `-V`
(string / []string) | `SARIN_VALUES`
(string) | - | Template values (key=value) | -| [Lua](#lua) | `lua`
(string / []string) | `-lua`
(string / []string) | `SARIN_LUA`
(string) | - | Lua script(s) | -| [Js](#js) | `js`
(string / []string) | `-js`
(string / []string) | `SARIN_JS`
(string) | - | JavaScript script(s) | +| Name | YAML | CLI | ENV | Default | Description | +| --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- | +| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | +| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | +| [Show Config](#show-config) | `showConfig`
(boolean) | `-show-config` / `-s`
(boolean) | `SARIN_SHOW_CONFIG`
(boolean) | `false` | Show merged configuration | +| [Config File](#config-file) | `configFile`
(string / []string) | `-config-file` / `-f`
(string / []string) | `SARIN_CONFIG_FILE`
(string) | - | Path to config file(s) | +| [URL](#url) | `url`
(string) | `-url` / `-U`
(string) | `SARIN_URL`
(string) | - | Target URL (HTTP/HTTPS) | +| [Method](#method) | `method`
(string / []string) | `-method` / `-M`
(string / []string) | `SARIN_METHOD`
(string) | `GET` | HTTP method(s) | +| [Timeout](#timeout) | `timeout`
(duration) | `-timeout` / `-T`
(duration) | `SARIN_TIMEOUT`
(duration) | `10s` | Request timeout | +| [Max Response Body](#max-response-body) | `maxResponseBody`
(size) | `-max-response-body` / `-S`
(size) | `SARIN_MAX_RESPONSE_BODY`
(size) | `10MiB` | Largest response body to read | +| [Concurrency](#concurrency) | `concurrency`
(number) | `-concurrency` / `-c`
(number) | `SARIN_CONCURRENCY`
(number) | `1` | Number of concurrent workers | +| [Requests](#requests) | `requests`
(number) | `-requests` / `-r`
(number) | `SARIN_REQUESTS`
(number) | - | Total requests to send | +| [Duration](#duration) | `duration`
(duration) | `-duration` / `-d`
(duration) | `SARIN_DURATION`
(duration) | - | Test duration | +| [Log Level](#log-level) | `logLevel`
(string) | `-log-level` / `-l`
(string) | `SARIN_LOG_LEVEL`
(string) | `error` | Runtime log levels to emit | +| [Log File](#log-file) | `logFile`
(string) | `-log-file` / `-w`
(string) | `SARIN_LOG_FILE`
(string) | - | Write runtime logs to a file | +| [Progress](#progress) | `progress`
(string) | `-progress` / `-p`
(string) | `SARIN_PROGRESS`
(string) | `bar` | Progress display (bar/none) | +| [Output](#output) | `output`
(string) | `-output` / `-o`
(string) | `SARIN_OUTPUT`
(string) | `table` | Output format for stats | +| [Dry Run](#dry-run) | `dryRun`
(boolean) | `-dry-run` / `-z`
(boolean) | `SARIN_DRY_RUN`
(boolean) | `false` | Generate without sending | +| [Insecure](#insecure) | `insecure`
(boolean) | `-insecure` / `-I`
(boolean) | `SARIN_INSECURE`
(boolean) | `false` | Skip TLS verification | +| [Body](#body) | `body`
(string / []string) | `-body` / `-B`
(string / []string) | `SARIN_BODY`
(string) | - | Request body | +| [Params](#params) | `params`
(object) | `-param` / `-P`
(string / []string) | `SARIN_PARAM`
(string) | - | URL query parameters | +| [Headers](#headers) | `headers`
(object) | `-header` / `-H`
(string / []string) | `SARIN_HEADER`
(string) | - | HTTP headers | +| [Cookies](#cookies) | `cookies`
(object) | `-cookie` / `-C`
(string / []string) | `SARIN_COOKIE`
(string) | - | HTTP cookies | +| [Proxy](#proxy) | `proxy`
(string / []string) | `-proxy` / `-X`
(string / []string) | `SARIN_PROXY`
(string) | - | Proxy URL(s) | +| [Values](#values) | `values`
(string / []string) | `-values` / `-V`
(string / []string) | `SARIN_VALUES`
(string) | - | Template values (key=value) | +| [Lua](#lua) | `lua`
(string / []string) | `-lua`
(string / []string) | `SARIN_LUA`
(string) | - | Lua script(s) | +| [Js](#js) | `js`
(string / []string) | `-js`
(string / []string) | `SARIN_JS`
(string) | - | JavaScript script(s) | --- @@ -168,6 +169,18 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` **Examples:** `5s`, `300ms`, `1m20s` +## Max Response Body + +Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit. + +The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1TiB`, or `2147483647` on a 32-bit platform. + +**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` + +A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. + +The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. It also applies to the requests scripts make, unless the script passes its own `maxBodySize` (see [HTTP Requests in Scripts](#http-requests-in-scripts)). + ## Concurrency Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`. @@ -583,6 +596,7 @@ http(url, opts) | `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 | +| `maxBodySize` | number | - | Largest response body to accept in bytes, maximum 1099511627776 | Unknown options and values of the wrong type raise an error. @@ -605,6 +619,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). +- **Response bodies:** without `maxBodySize` the [Max Response Body](#max-response-body) config applies, as it does to the main request. With `maxBodySize` the body is read as it arrives, so a `Content-Length` the server never sends costs nothing, the limit is exact, and it may be larger than the config. Passing the limit raises an error instead of truncating the body. - **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. diff --git a/internal/config/bytesize.go b/internal/config/bytesize.go new file mode 100644 index 0000000..c82b71b --- /dev/null +++ b/internal/config/bytesize.go @@ -0,0 +1,123 @@ +package config + +import ( + "flag" + "fmt" + "math" + "strconv" + "strings" + + "go.aykhans.me/sarin/internal/types" + "go.yaml.in/yaml/v4" +) + +var ( + _ flag.Value = (*byteSize)(nil) + _ yaml.Unmarshaler = (*byteSize)(nil) +) + +// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB. +type byteSize uint64 + +// byteSizeUnits run from the largest scale down, so formatting picks the largest unit +// that fits. A bare "K" is out: the common libraries disagree on whether it counts +// 1000 or 1024 bytes. +var byteSizeUnits = []struct { + suffix string + scale uint64 +}{ + {"TiB", 1 << 40}, + {"TB", 1000 * 1000 * 1000 * 1000}, + {"GiB", 1 << 30}, + {"GB", 1000 * 1000 * 1000}, + {"MiB", 1 << 20}, + {"MB", 1000 * 1000}, + {"KiB", 1 << 10}, + {"KB", 1000}, + {"B", 1}, +} + +// parseByteSize reads a byte count, with or without a unit suffix. +func parseByteSize(value string) (uint64, error) { + text := strings.TrimSpace(value) + + // The suffix is split off by index, so no case mapping can shift the digits. + digitsEnd := len(text) + for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) { + digitsEnd-- + } + + scale := uint64(1) + if suffix := text[digitsEnd:]; suffix != "" { + unitScale, ok := byteSizeScale(suffix) + if !ok { + return 0, types.ErrByteSizeInvalid + } + scale = unitScale + } + + size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64) + if err != nil { + return 0, types.ErrByteSizeInvalid + } + if size > math.MaxUint64/scale { + return 0, types.ErrByteSizeInvalid + } + return size * scale, nil +} + +// byteSizeScale looks up a unit suffix, ignoring case. +func byteSizeScale(suffix string) (uint64, bool) { + for _, unit := range byteSizeUnits { + if strings.EqualFold(suffix, unit.suffix) { + return unit.scale, true + } + } + return 0, false +} + +func isASCIILetter(char byte) bool { + return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') +} + +// formatByteSize renders a byte count so that parseByteSize reads back the same value. +func formatByteSize(size uint64) string { + for _, unit := range byteSizeUnits { + if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 { + return strconv.FormatUint(size/unit.scale, 10) + unit.suffix + } + } + return strconv.FormatUint(size, 10) +} + +func (size byteSize) String() string { + return formatByteSize(uint64(size)) +} + +// Set fills the size from a command line value. +func (size *byteSize) Set(value string) error { + parsed, err := parseByteSize(value) + if err != nil { + return err + } + *size = byteSize(parsed) + return nil +} + +// UnmarshalYAML accepts both a YAML integer and a string with a unit. +func (size *byteSize) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.ScalarNode { + return fmt.Errorf("expected a size, but got %s", node.Tag) + } + + if node.Tag == "!!int" { + var parsed uint64 + if err := node.Decode(&parsed); err != nil { + return types.ErrByteSizeInvalid + } + *size = byteSize(parsed) + return nil + } + + return size.Set(node.Value) +} diff --git a/internal/config/cli.go b/internal/config/cli.go index e540aeb..ee12fda 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -20,32 +20,33 @@ Simple usage: Flags: General Config: - -h, -help Help for sarin - -v, -version Version for sarin - -s, -show-config bool Show the final config after parsing all sources (default %v) - -f, -config-file []string Path to the config file (local file / http URL) - -c, -concurrency uint Number of concurrent requests (default %d) - -r, -requests uint Number of total requests - -d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h) - -l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s) - -w, -log-file string Write runtime logs to this file instead of the terminal/stderr - -p, -progress string Progress display (possible values: bar, none) (default '%v') - -o, -output string Output format (possible values: table, json, yaml, none) (default '%v') - -z, -dry-run bool Run without sending requests (default %v) + -h, -help Help for sarin + -v, -version Version for sarin + -s, -show-config bool Show the final config after parsing all sources (default %v) + -f, -config-file []string Path to the config file (local file / http URL) + -c, -concurrency uint Number of concurrent requests (default %d) + -r, -requests uint Number of total requests + -d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h) + -l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s) + -w, -log-file string Write runtime logs to this file instead of the terminal/stderr + -p, -progress string Progress display (possible values: bar, none) (default '%v') + -o, -output string Output format (possible values: table, json, yaml, none) (default '%v') + -z, -dry-run bool Run without sending requests (default %v) Request Config: - -U, -url string Target URL for the request - -M, -method []string HTTP method for the request (default %s) - -B, -body []string Body for the request (e.g. "body text") - -P, -param []string URL parameter for the request (e.g. "key1=value1") - -H, -header []string Header for the request (e.g. "key1: value1") - -C, -cookie []string Cookie for the request (e.g. "key1=value1") - -X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080") - -V, -values []string List of values for templating (e.g. "key1=value1") - -T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v) - -I, -insecure bool Skip SSL/TLS certificate verification (default %v) - -lua []string Lua script for request transformation (inline or @file/@url) - -js []string JavaScript script for request transformation (inline or @file/@url)` + -U, -url string Target URL for the request + -M, -method []string HTTP method for the request (default %s) + -B, -body []string Body for the request (e.g. "body text") + -P, -param []string URL parameter for the request (e.g. "key1=value1") + -H, -header []string Header for the request (e.g. "key1: value1") + -C, -cookie []string Cookie for the request (e.g. "key1=value1") + -X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080") + -V, -values []string List of values for templating (e.g. "key1=value1") + -T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v) + -S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s) + -I, -insecure bool Skip SSL/TLS certificate verification (default %v) + -lua []string Lua script for request transformation (inline or @file/@url) + -js []string JavaScript script for request transformation (inline or @file/@url)` var _ IParser = ConfigCLIParser{} @@ -97,18 +98,19 @@ 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 - luaScripts = stringSliceArg{} - jsScripts = stringSliceArg{} + urlInput string + methods = stringSliceArg{} + bodies = stringSliceArg{} + params = stringSliceArg{} + headers = stringSliceArg{} + cookies = stringSliceArg{} + proxies = stringSliceArg{} + values = stringSliceArg{} + timeout time.Duration + maxResponseBody byteSize + insecure bool + luaScripts = stringSliceArg{} + jsScripts = stringSliceArg{} ) { @@ -174,6 +176,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") + flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit") + flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit") + flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification") @@ -258,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { config.Values = append(config.Values, values...) case "timeout", "T": config.Timeout = new(timeout) + case "max-response-body", "S": + config.MaxResponseBody = new(uint64(maxResponseBody)) case "insecure", "I": config.Insecure = new(insecure) case "lua": @@ -286,6 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() { Defaults.Method, Defaults.RequestTimeout, + formatByteSize(Defaults.MaxResponseBody), Defaults.Insecure, ) } diff --git a/internal/config/config.go b/internal/config/config.go index dc5252d..298b418 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "net/url" "os" "path/filepath" @@ -27,27 +28,29 @@ import ( ) var Defaults = struct { - UserAgent string - Method string - RequestTimeout time.Duration - Concurrency uint - ShowConfig bool - Progress ConfigProgressType - Insecure bool - Output ConfigOutputType - DryRun bool - LogLevel string + UserAgent string + Method string + RequestTimeout time.Duration + MaxResponseBody uint64 + Concurrency uint + ShowConfig bool + Progress ConfigProgressType + Insecure bool + Output ConfigOutputType + DryRun bool + LogLevel string }{ - UserAgent: "Sarin/" + version.Version, - Method: "GET", - RequestTimeout: time.Second * 10, - Concurrency: 1, - ShowConfig: false, - Progress: ConfigProgressTypeBar, - Insecure: false, - Output: ConfigOutputTypeTable, - DryRun: false, - LogLevel: "error", + UserAgent: "Sarin/" + version.Version, + Method: "GET", + RequestTimeout: time.Second * 10, + MaxResponseBody: 10 << 20, // 10 MiB + Concurrency: 1, + ShowConfig: false, + Progress: ConfigProgressTypeBar, + Insecure: false, + Output: ConfigOutputTypeTable, + DryRun: false, + LogLevel: "error", } var ( @@ -56,6 +59,8 @@ var ( ValidLogLevels = []string{"info", "error"} ) +const maxResponseBodyLimit = min(1<<40, math.MaxInt) // 1 TiB on 64 bit, 2 GiB - 1 on 32 bit + var ( StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) @@ -82,28 +87,29 @@ var ( ) type Config struct { - ShowConfig *bool `yaml:"showConfig,omitempty"` - Files []types.ConfigFile `yaml:"files,omitempty"` - Methods []string `yaml:"methods,omitempty"` - URL *url.URL `yaml:"url,omitempty"` - Timeout *time.Duration `yaml:"timeout,omitempty"` - Concurrency *uint `yaml:"concurrency,omitempty"` - Requests *uint64 `yaml:"requests,omitempty"` - Duration *time.Duration `yaml:"duration,omitempty"` - Progress *ConfigProgressType `yaml:"progress,omitempty"` - Output *ConfigOutputType `yaml:"output,omitempty"` - Insecure *bool `yaml:"insecure,omitempty"` - DryRun *bool `yaml:"dryRun,omitempty"` - Params types.Params `yaml:"params,omitempty"` - Headers types.Headers `yaml:"headers,omitempty"` - Cookies types.Cookies `yaml:"cookies,omitempty"` - Bodies []string `yaml:"bodies,omitempty"` - Proxies types.Proxies `yaml:"proxies,omitempty"` - Values []string `yaml:"values,omitempty"` - Lua []string `yaml:"lua,omitempty"` - Js []string `yaml:"js,omitempty"` - LogLevel *string `yaml:"logLevel,omitempty"` - LogFile *string `yaml:"logFile,omitempty"` + ShowConfig *bool `yaml:"showConfig,omitempty"` + Files []types.ConfigFile `yaml:"files,omitempty"` + Methods []string `yaml:"methods,omitempty"` + URL *url.URL `yaml:"url,omitempty"` + Timeout *time.Duration `yaml:"timeout,omitempty"` + MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"` + Concurrency *uint `yaml:"concurrency,omitempty"` + Requests *uint64 `yaml:"requests,omitempty"` + Duration *time.Duration `yaml:"duration,omitempty"` + Progress *ConfigProgressType `yaml:"progress,omitempty"` + Output *ConfigOutputType `yaml:"output,omitempty"` + Insecure *bool `yaml:"insecure,omitempty"` + DryRun *bool `yaml:"dryRun,omitempty"` + Params types.Params `yaml:"params,omitempty"` + Headers types.Headers `yaml:"headers,omitempty"` + Cookies types.Cookies `yaml:"cookies,omitempty"` + Bodies []string `yaml:"bodies,omitempty"` + Proxies types.Proxies `yaml:"proxies,omitempty"` + Values []string `yaml:"values,omitempty"` + Lua []string `yaml:"lua,omitempty"` + Js []string `yaml:"js,omitempty"` + LogLevel *string `yaml:"logLevel,omitempty"` + LogFile *string `yaml:"logFile,omitempty"` } func (config Config) MarshalYAML() (any, error) { @@ -177,6 +183,9 @@ func (config Config) MarshalYAML() (any, error) { if config.Timeout != nil { addField(content, "timeout", toNode(*config.Timeout), "") } + if config.MaxResponseBody != nil { + addField(content, "maxResponseBody", toNode(formatByteSize(*config.MaxResponseBody)), "") + } if config.Concurrency != nil { addField(content, "concurrency", toNode(*config.Concurrency), "") } @@ -301,6 +310,9 @@ func (config *Config) Merge(newConfig *Config) { if newConfig.Timeout != nil { config.Timeout = newConfig.Timeout } + if newConfig.MaxResponseBody != nil { + config.MaxResponseBody = newConfig.MaxResponseBody + } if newConfig.Concurrency != nil { config.Concurrency = newConfig.Concurrency } @@ -379,6 +391,9 @@ func (config *Config) SetDefaults() { if config.Timeout == nil { config.Timeout = &Defaults.RequestTimeout } + if config.MaxResponseBody == nil { + config.MaxResponseBody = &Defaults.MaxResponseBody + } if config.Concurrency == nil { config.Concurrency = new(Defaults.Concurrency) } @@ -454,6 +469,14 @@ func (config Config) Validate() error { validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) } + if config.MaxResponseBody != nil && *config.MaxResponseBody > maxResponseBodyLimit { + validationErrors = append(validationErrors, types.NewFieldValidationError( + "MaxResponseBody", + formatByteSize(*config.MaxResponseBody), + errors.New("max response body must not exceed "+formatByteSize(maxResponseBodyLimit)), + )) + } + if config.ShowConfig == nil { validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required"))) } diff --git a/internal/config/env.go b/internal/config/env.go index 73cd46e..345cd2a 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -195,6 +195,22 @@ func (parser ConfigENVParser) Parse() (*Config, error) { } } + if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" { + maxResponseBodyParsed, err := parseByteSize(maxResponseBody) + if err != nil { + fieldParseErrors = append( + fieldParseErrors, + types.NewFieldParseError( + parser.getFullEnvName("MAX_RESPONSE_BODY"), + maxResponseBody, + err, + ), + ) + } else { + config.MaxResponseBody = &maxResponseBodyParsed + } + } + if insecure := parser.getEnv("INSECURE"); insecure != "" { insecureParsed, err := utilsParse.ParseString[bool](insecure) if err != nil { diff --git a/internal/config/file.go b/internal/config/file.go index d5c95f0..d2d088d 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -192,28 +192,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error { } type configYAML struct { - ShowConfig *bool `yaml:"showConfig"` - ConfigFiles stringOrSliceField `yaml:"configFile"` - Concurrency *uint `yaml:"concurrency"` - RequestCount *uint64 `yaml:"requests"` - Duration *time.Duration `yaml:"duration"` - LogLevel *string `yaml:"logLevel"` - LogFile *string `yaml:"logFile"` - Progress *string `yaml:"progress"` - Output *string `yaml:"output"` - DryRun *bool `yaml:"dryRun"` - URL *string `yaml:"url"` - Method stringOrSliceField `yaml:"method"` - Bodies stringOrSliceField `yaml:"body"` - Params keyValuesField `yaml:"params"` - Headers keyValuesField `yaml:"headers"` - Cookies keyValuesField `yaml:"cookies"` - Proxies stringOrSliceField `yaml:"proxy"` - Values stringOrSliceField `yaml:"values"` - Timeout *time.Duration `yaml:"timeout"` - Insecure *bool `yaml:"insecure"` - Lua stringOrSliceField `yaml:"lua"` - Js stringOrSliceField `yaml:"js"` + ShowConfig *bool `yaml:"showConfig"` + ConfigFiles stringOrSliceField `yaml:"configFile"` + Concurrency *uint `yaml:"concurrency"` + RequestCount *uint64 `yaml:"requests"` + Duration *time.Duration `yaml:"duration"` + LogLevel *string `yaml:"logLevel"` + LogFile *string `yaml:"logFile"` + Progress *string `yaml:"progress"` + Output *string `yaml:"output"` + DryRun *bool `yaml:"dryRun"` + URL *string `yaml:"url"` + Method stringOrSliceField `yaml:"method"` + Bodies stringOrSliceField `yaml:"body"` + Params keyValuesField `yaml:"params"` + Headers keyValuesField `yaml:"headers"` + Cookies keyValuesField `yaml:"cookies"` + Proxies stringOrSliceField `yaml:"proxy"` + Values stringOrSliceField `yaml:"values"` + Timeout *time.Duration `yaml:"timeout"` + MaxResponseBody *byteSize `yaml:"maxResponseBody"` + Insecure *bool `yaml:"insecure"` + Lua stringOrSliceField `yaml:"lua"` + Js stringOrSliceField `yaml:"js"` } // ParseYAML parses YAML config file arguments into a Config object. @@ -290,6 +291,9 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { config.Values = append(config.Values, parsedData.Values...) config.Timeout = parsedData.Timeout + if parsedData.MaxResponseBody != nil { + config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody)) + } config.Insecure = parsedData.Insecure config.Lua = append(config.Lua, parsedData.Lua...) config.Js = append(config.Js, parsedData.Js...) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index 0ecc32a..a71c06e 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "encoding/base64" - "math" "net" "net/http" "net/url" @@ -20,13 +19,6 @@ import ( // clientIndexGenerator returns the index of the host client (and proxy) to use next. type clientIndexGenerator func() int -func safeUintToInt(u uint) int { - if u > math.MaxInt { - return math.MaxInt - } - return int(u) -} - // NewHostClients creates a list of fasthttp.HostClient instances for the given proxies. // If no proxies are provided, a single client without a proxy is returned. // It can return the following errors: @@ -38,6 +30,7 @@ func NewHostClients( maxConns uint, requestURL *url.URL, skipVerify bool, + maxResponseBody uint64, ) ([]*fasthttp.HostClient, error) { isTLS := requestURL.Scheme == "https" @@ -66,6 +59,7 @@ func NewHostClients( MaxConnDuration: timeout, WriteTimeout: timeout, ReadTimeout: timeout, + MaxResponseBodySize: safeUint64ToInt(maxResponseBody), DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, @@ -88,6 +82,7 @@ func NewHostClients( MaxConnDuration: timeout, WriteTimeout: timeout, ReadTimeout: timeout, + MaxResponseBodySize: safeUint64ToInt(maxResponseBody), DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go index 6e70365..7cd6de1 100644 --- a/internal/sarin/helpers.go +++ b/internal/sarin/helpers.go @@ -1,6 +1,7 @@ package sarin import ( + "math" "math/rand/v2" "time" ) @@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string { } return values[0] } + +func safeUintToInt(u uint) int { + if u > math.MaxInt { + return math.MaxInt + } + return int(u) +} + +func safeUint64ToInt(u uint64) int { + if u > math.MaxInt { + return math.MaxInt + } + return int(u) +} diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index cfaafd6..60b72e8 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -225,6 +225,7 @@ func NewSarin( totalDuration *time.Duration, showProgress bool, skipCertVerify bool, + maxResponseBody uint64, params types.Params, headers types.Headers, cookies types.Cookies, @@ -258,7 +259,7 @@ func NewSarin( proxyURLs[i] = url.URL(proxy) } - hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify) + hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody) if err != nil { return nil, err } @@ -280,7 +281,7 @@ func NewSarin( var scriptHTTPClients []*scriptHTTPClient if !scriptChain.IsEmpty() { - scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers) + scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody) if err != nil { jobsCancel() return nil, err diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index de1c567..33f6e80 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -1,8 +1,11 @@ package sarin import ( + "bytes" "context" "crypto/tls" + "errors" + "io" "net" "net/url" "time" @@ -28,9 +31,9 @@ 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) { +func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) @@ -39,18 +42,19 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns)) + clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)) } return clients, nil } -func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { +func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint, maxResponseBody uint64) *scriptHTTPClient { newClient := func(insecure bool) *fasthttp.Client { // Read and write timeouts stay unset because they would cap the per-call timeout, // so dialWithDeadline is what bounds the TLS handshake. return &fasthttp.Client{ - DialTimeout: dialWithDeadline(dial), - MaxConnsPerHost: safeUintToInt(maxConns), + DialTimeout: dialWithDeadline(dial), + MaxConnsPerHost: safeUintToInt(maxConns), + MaxResponseBodySize: safeUint64ToInt(maxResponseBody), TLSConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec }, @@ -144,6 +148,8 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro } req.SetTimeout(timeout) + // A body limit streams the response, so a declared Content-Length is never allocated. + resp.StreamBody = r.MaxBodySize > 0 if r.MaxRedirects > 0 { err = client.DoRedirects(req, resp, r.MaxRedirects) } else { @@ -153,9 +159,47 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } + body, err := readScriptBody(resp, r.MaxBodySize) + 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()), + Body: body, }, nil } + +// readScriptBody reads the streamed body, refusing it once it passes maxBodySize. +// It can return the following errors: +// - types.ErrScriptHTTPBodyTooLarge +func readScriptBody(resp *fasthttp.Response, maxBodySize int64) (string, error) { + stream := resp.BodyStream() + if stream == nil { + return string(resp.Body()), nil + } + + var ( + body bytes.Buffer + err error + ) + if maxBodySize > 0 { + // One byte past the limit tells an exact fit from an overflow. + _, err = io.CopyN(&body, stream, maxBodySize+1) + if err == nil { + err = types.ErrScriptHTTPBodyTooLarge + } else if errors.Is(err, io.EOF) { + err = nil + } + } else { + _, err = io.Copy(&body, stream) + } + + // An unfinished stream makes fasthttp drop the connection instead of pooling it. + _ = resp.CloseBodyStream() + if err != nil { + return "", err + } + return body.String(), nil +} diff --git a/internal/sarin/worker.go b/internal/sarin/worker.go index 2889c73..2d0897e 100644 --- a/internal/sarin/worker.go +++ b/internal/sarin/worker.go @@ -2,7 +2,9 @@ package sarin import ( "errors" + "net" "strconv" + "strings" "sync/atomic" "time" @@ -31,6 +33,20 @@ func statusCodeToString(code int) string { return strconv.Itoa(code) } +// responseErrorKey drops the local address, whose port would mint a stats entry per connection. +func responseErrorKey(err error) string { + message := err.Error() + + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Source == nil { + return message + } + + withoutSource := *opErr + withoutSource.Source = nil + return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1) +} + // requestErrorKey keys a generation failure by its source instead of its message. // A script picks its own URLs and error texts, so the message would mint a new // stats entry per request. The full message still goes to the runtime log. @@ -135,7 +151,7 @@ func (s sarin) workerStatsWithDynamic( respDuration := time.Since(startTime) if err != nil { - s.responses.Add(err.Error(), respDuration) + s.responses.Add(responseErrorKey(err), respDuration) } else { s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) sendRespLog(respDuration, resp) @@ -170,7 +186,7 @@ func (s sarin) workerStatsWithStatic( err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout) respDuration := time.Since(startTime) if err != nil { - s.responses.Add(err.Error(), respDuration) + s.responses.Add(responseErrorKey(err), respDuration) } else { s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) sendRespLog(respDuration, resp) diff --git a/internal/script/http.go b/internal/script/http.go index 0b80b1f..9c97fd9 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -21,6 +21,7 @@ type HTTPRequest struct { Timeout time.Duration // zero means the doer's default timeout Insecure bool MaxRedirects int + MaxBodySize int64 // zero means no limit } // HTTPResponse is the response returned to a script by the http.* functions. @@ -66,6 +67,9 @@ const httpDefaultMethod = http.MethodGet // httpMaxRedirectsLimit is the highest maxRedirects a script may ask for. const httpMaxRedirectsLimit = 100 +// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for, 1 TiB. +const httpMaxBodySizeLimit int64 = 1 << 40 + // Option keys accepted by the http function. const ( httpOptionMethod = "method" @@ -76,6 +80,7 @@ const ( httpOptionTimeout = "timeout" httpOptionInsecure = "insecure" httpOptionMaxRedirects = "maxRedirects" + httpOptionMaxBodySize = "maxBodySize" ) // httpBridge connects http to the doer. Calls only work while transform runs. @@ -118,6 +123,22 @@ func parseHTTPMaxRedirects(count float64) (int, error) { return int(count), nil } +// parseHTTPMaxBodySize checks that the body limit is a non-negative whole number. +// It can return the following errors: +// - types.ScriptHTTPOptionError +func parseHTTPMaxBodySize(size float64) (int64, error) { + if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) { + return 0, types.NewScriptHTTPOptionError( + httpOptionMaxBodySize, + types.NewScriptTypeError( + "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), + strconv.FormatFloat(size, 'g', -1, 64), + ), + ) + } + return int64(size), nil +} + // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". // It can return the following errors: // - types.ScriptHTTPOptionError diff --git a/internal/script/js_http.go b/internal/script/js_http.go index 7cdce59..0a085b3 100644 --- a/internal/script/js_http.go +++ b/internal/script/js_http.go @@ -117,6 +117,16 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error { if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil { return err } + case httpOptionMaxBodySize: + size, ok := numberValue(value) + if !ok { + return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value))) + } + + var err error + if req.MaxBodySize, err = parseHTTPMaxBodySize(size); err != nil { + return err + } default: return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) } diff --git a/internal/script/lua_http.go b/internal/script/lua_http.go index a1e3a14..bab4dfb 100644 --- a/internal/script/lua_http.go +++ b/internal/script/lua_http.go @@ -106,6 +106,13 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error { return } req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count)) + case httpOptionMaxBodySize: + size, ok := v.(lua.LNumber) + if !ok { + err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String())) + return + } + req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size)) default: err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) } diff --git a/internal/types/errors.go b/internal/types/errors.go index 2b059f0..16b0d9e 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -10,6 +10,11 @@ import ( var ( errNoError = errors.New("no error (internal)") + + ErrByteSizeInvalid = errors.New( + "invalid value for size, expected a whole number of bytes or a whole number with a unit: " + + "B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'", + ) ) type FieldParseError struct { @@ -408,6 +413,7 @@ var ( 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") + ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds maxBodySize") ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself") ) From 1956eb1c442097939d733bd6c54babde0f855a54 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 00:41:48 +0400 Subject: [PATCH 16/40] fix(script): enforce script body limits without streaming Streaming accepted a chunked body cut at a chunk boundary as complete, so the limit now comes from the client that reads the body, picked per requested size. Script requests keep their own default, maxBodySize accepts sizes like 10MiB, and files sarin downloads for itself are capped at 64MiB. --- docs/configuration.md | 6 +- internal/config/bytesize.go | 78 +-------------------- internal/config/cli.go | 2 +- internal/config/config.go | 11 ++- internal/config/env.go | 2 +- internal/config/file.go | 5 +- internal/sarin/filecache.go | 5 +- internal/sarin/helpers.go | 7 ++ internal/sarin/runner.go | 2 +- internal/sarin/scripthttp.go | 130 +++++++++++++++++------------------ internal/script/http.go | 23 ++++++- internal/script/js_http.go | 17 +++-- internal/script/lua_http.go | 12 ++-- internal/script/script.go | 5 +- internal/types/byte_size.go | 86 +++++++++++++++++++++++ internal/types/errors.go | 4 +- 16 files changed, 222 insertions(+), 173 deletions(-) create mode 100644 internal/types/byte_size.go diff --git a/docs/configuration.md b/docs/configuration.md index 8020761..934e2e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -179,7 +179,7 @@ The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, ` A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. -The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. It also applies to the requests scripts make, unless the script passes its own `maxBodySize` (see [HTTP Requests in Scripts](#http-requests-in-scripts)). +The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). ## Concurrency @@ -596,7 +596,7 @@ http(url, opts) | `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 | -| `maxBodySize` | number | - | Largest response body to accept in bytes, maximum 1099511627776 | +| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept (the max response body config does not apply), maximum `1TiB` | Unknown options and values of the wrong type raise an error. @@ -619,7 +619,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). -- **Response bodies:** without `maxBodySize` the [Max Response Body](#max-response-body) config applies, as it does to the main request. With `maxBodySize` the body is read as it arrives, so a `Content-Length` the server never sends costs nothing, the limit is exact, and it may be larger than the config. Passing the limit raises an error instead of truncating the body. +- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. - **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. diff --git a/internal/config/bytesize.go b/internal/config/bytesize.go index c82b71b..50abbc4 100644 --- a/internal/config/bytesize.go +++ b/internal/config/bytesize.go @@ -3,9 +3,6 @@ package config import ( "flag" "fmt" - "math" - "strconv" - "strings" "go.aykhans.me/sarin/internal/types" "go.yaml.in/yaml/v4" @@ -19,84 +16,13 @@ var ( // byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB. type byteSize uint64 -// byteSizeUnits run from the largest scale down, so formatting picks the largest unit -// that fits. A bare "K" is out: the common libraries disagree on whether it counts -// 1000 or 1024 bytes. -var byteSizeUnits = []struct { - suffix string - scale uint64 -}{ - {"TiB", 1 << 40}, - {"TB", 1000 * 1000 * 1000 * 1000}, - {"GiB", 1 << 30}, - {"GB", 1000 * 1000 * 1000}, - {"MiB", 1 << 20}, - {"MB", 1000 * 1000}, - {"KiB", 1 << 10}, - {"KB", 1000}, - {"B", 1}, -} - -// parseByteSize reads a byte count, with or without a unit suffix. -func parseByteSize(value string) (uint64, error) { - text := strings.TrimSpace(value) - - // The suffix is split off by index, so no case mapping can shift the digits. - digitsEnd := len(text) - for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) { - digitsEnd-- - } - - scale := uint64(1) - if suffix := text[digitsEnd:]; suffix != "" { - unitScale, ok := byteSizeScale(suffix) - if !ok { - return 0, types.ErrByteSizeInvalid - } - scale = unitScale - } - - size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64) - if err != nil { - return 0, types.ErrByteSizeInvalid - } - if size > math.MaxUint64/scale { - return 0, types.ErrByteSizeInvalid - } - return size * scale, nil -} - -// byteSizeScale looks up a unit suffix, ignoring case. -func byteSizeScale(suffix string) (uint64, bool) { - for _, unit := range byteSizeUnits { - if strings.EqualFold(suffix, unit.suffix) { - return unit.scale, true - } - } - return 0, false -} - -func isASCIILetter(char byte) bool { - return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') -} - -// formatByteSize renders a byte count so that parseByteSize reads back the same value. -func formatByteSize(size uint64) string { - for _, unit := range byteSizeUnits { - if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 { - return strconv.FormatUint(size/unit.scale, 10) + unit.suffix - } - } - return strconv.FormatUint(size, 10) -} - func (size byteSize) String() string { - return formatByteSize(uint64(size)) + return types.FormatByteSize(uint64(size)) } // Set fills the size from a command line value. func (size *byteSize) Set(value string) error { - parsed, err := parseByteSize(value) + parsed, err := types.ParseByteSize(value) if err != nil { return err } diff --git a/internal/config/cli.go b/internal/config/cli.go index ee12fda..67e665d 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -293,7 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() { Defaults.Method, Defaults.RequestTimeout, - formatByteSize(Defaults.MaxResponseBody), + types.FormatByteSize(Defaults.MaxResponseBody), Defaults.Insecure, ) } diff --git a/internal/config/config.go b/internal/config/config.go index 298b418..397dede 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "net/url" "os" "path/filepath" @@ -59,8 +58,6 @@ var ( ValidLogLevels = []string{"info", "error"} ) -const maxResponseBodyLimit = min(1<<40, math.MaxInt) // 1 TiB on 64 bit, 2 GiB - 1 on 32 bit - var ( StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) @@ -184,7 +181,7 @@ func (config Config) MarshalYAML() (any, error) { addField(content, "timeout", toNode(*config.Timeout), "") } if config.MaxResponseBody != nil { - addField(content, "maxResponseBody", toNode(formatByteSize(*config.MaxResponseBody)), "") + addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "") } if config.Concurrency != nil { addField(content, "concurrency", toNode(*config.Concurrency), "") @@ -469,11 +466,11 @@ func (config Config) Validate() error { validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) } - if config.MaxResponseBody != nil && *config.MaxResponseBody > maxResponseBodyLimit { + if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit { validationErrors = append(validationErrors, types.NewFieldValidationError( "MaxResponseBody", - formatByteSize(*config.MaxResponseBody), - errors.New("max response body must not exceed "+formatByteSize(maxResponseBodyLimit)), + types.FormatByteSize(*config.MaxResponseBody), + errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)), )) } diff --git a/internal/config/env.go b/internal/config/env.go index 345cd2a..8d00a09 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -196,7 +196,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) { } if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" { - maxResponseBodyParsed, err := parseByteSize(maxResponseBody) + maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody) if err != nil { fieldParseErrors = append( fieldParseErrors, diff --git a/internal/config/file.go b/internal/config/file.go index d2d088d..a774610 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -79,10 +79,13 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) { return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return nil, types.NewHTTPFetchError(url, err) } + if len(data) > types.RemoteFetchLimit { + return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } return data, nil } diff --git a/internal/sarin/filecache.go b/internal/sarin/filecache.go index 1fb7d00..d451c97 100644 --- a/internal/sarin/filecache.go +++ b/internal/sarin/filecache.go @@ -93,10 +93,13 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) { return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } - content, err := io.ReadAll(resp.Body) + content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return nil, "", types.NewHTTPFetchError(url, err) } + if len(content) > types.RemoteFetchLimit { + return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } // Extract filename from URL path filename := path.Base(url) diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go index 7cd6de1..91c15b5 100644 --- a/internal/sarin/helpers.go +++ b/internal/sarin/helpers.go @@ -34,3 +34,10 @@ func safeUint64ToInt(u uint64) int { } return int(u) } + +func safeInt64ToInt(i int64) int { + if i > math.MaxInt { + return math.MaxInt + } + return int(i) +} diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 60b72e8..bf7e7b3 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -281,7 +281,7 @@ func NewSarin( var scriptHTTPClients []*scriptHTTPClient if !scriptChain.IsEmpty() { - scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody) + scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers) if err != nil { jobsCancel() return nil, err diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 33f6e80..2652417 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -1,13 +1,12 @@ package sarin import ( - "bytes" "context" "crypto/tls" "errors" - "io" "net" "net/url" + "sync" "time" "github.com/valyala/fasthttp" @@ -18,11 +17,22 @@ import ( // scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T. const scriptHTTPDefaultTimeout = 30 * time.Second +// scriptHTTPDefaultMaxBodySize is the default body limit for script requests, +// independent of the max response body config. +const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB + +// scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body +// limit per client rather than per request. +type scriptHTTPClientKey struct { + insecure bool + maxBodySize int +} + // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { ctx context.Context //nolint:containedctx - client *fasthttp.Client - insecureClient *fasthttp.Client + newClient func(key scriptHTTPClientKey) *fasthttp.Client + clients sync.Map defaultTimeout time.Duration } @@ -31,9 +41,9 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil) // newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients. // It can return the following errors: // - types.ProxyDialError -func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) { +func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) @@ -42,36 +52,47 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)) + clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns)) } return clients, nil } -func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint, maxResponseBody uint64) *scriptHTTPClient { - newClient := func(insecure bool) *fasthttp.Client { - // Read and write timeouts stay unset because they would cap the per-call timeout, - // so dialWithDeadline is what bounds the TLS handshake. - return &fasthttp.Client{ - DialTimeout: dialWithDeadline(dial), - MaxConnsPerHost: safeUintToInt(maxConns), - MaxResponseBodySize: safeUint64ToInt(maxResponseBody), - TLSConfig: &tls.Config{ - InsecureSkipVerify: insecure, //nolint:gosec - }, - DisableHeaderNamesNormalizing: true, - DisablePathNormalizing: true, - NoDefaultUserAgentHeader: true, - } - } - +func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { return &scriptHTTPClient{ - ctx: ctx, - client: newClient(false), - insecureClient: newClient(true), + ctx: ctx, + newClient: func(key scriptHTTPClientKey) *fasthttp.Client { + // Read and write timeouts stay unset because they would cap the per-call timeout, + // so dialWithDeadline is what bounds the TLS handshake. + return &fasthttp.Client{ + DialTimeout: dialWithDeadline(dial), + MaxConnsPerHost: safeUintToInt(maxConns), + MaxResponseBodySize: key.maxBodySize, + TLSConfig: &tls.Config{ + InsecureSkipVerify: key.insecure, //nolint:gosec + }, + DisableHeaderNamesNormalizing: true, + DisablePathNormalizing: true, + NoDefaultUserAgentHeader: true, + } + }, defaultTimeout: defaultTimeout, } } +// scriptHTTPBodyCap rounds a limit up to a power of two, so a script that varies its +// limit cannot mint a client, and a connection pool with it, per request. +func scriptHTTPBodyCap(maxBodySize int64) int { + if maxBodySize <= 0 { + return scriptHTTPDefaultMaxBodySize + } + + size := int64(1) + for size < maxBodySize { + size <<= 1 + } + return safeInt64ToInt(size) +} + // 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 { @@ -142,64 +163,41 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro timeout = c.defaultTimeout } - client := c.client - if r.Insecure { - client = c.insecureClient - } + client := c.clientFor(r.Insecure, r.MaxBodySize) req.SetTimeout(timeout) - // A body limit streams the response, so a declared Content-Length is never allocated. - resp.StreamBody = r.MaxBodySize > 0 if r.MaxRedirects > 0 { err = client.DoRedirects(req, resp, r.MaxRedirects) } else { err = client.Do(req, resp) } if err != nil { + if errors.Is(err, fasthttp.ErrBodyTooLarge) { + err = types.ErrScriptHTTPBodyTooLarge + } return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } - body, err := readScriptBody(resp, r.MaxBodySize) - if err != nil { - return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) + // The client caps the body at the rounded up limit, so the exact one is checked here. + body := resp.Body() + if r.MaxBodySize > 0 && int64(len(body)) > r.MaxBodySize { + return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPBodyTooLarge) } return &script.HTTPResponse{ Status: resp.StatusCode(), Headers: collectRespHeaders(resp), - Body: body, + Body: string(body), }, nil } -// readScriptBody reads the streamed body, refusing it once it passes maxBodySize. -// It can return the following errors: -// - types.ErrScriptHTTPBodyTooLarge -func readScriptBody(resp *fasthttp.Response, maxBodySize int64) (string, error) { - stream := resp.BodyStream() - if stream == nil { - return string(resp.Body()), nil +// clientFor returns the client whose body limit covers maxBodySize, building it once. +func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client { + key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)} + if client, ok := c.clients.Load(key); ok { + return client.(*fasthttp.Client) //nolint:forcetypeassert } - var ( - body bytes.Buffer - err error - ) - if maxBodySize > 0 { - // One byte past the limit tells an exact fit from an overflow. - _, err = io.CopyN(&body, stream, maxBodySize+1) - if err == nil { - err = types.ErrScriptHTTPBodyTooLarge - } else if errors.Is(err, io.EOF) { - err = nil - } - } else { - _, err = io.Copy(&body, stream) - } - - // An unfinished stream makes fasthttp drop the connection instead of pooling it. - _ = resp.CloseBodyStream() - if err != nil { - return "", err - } - return body.String(), nil + client, _ := c.clients.LoadOrStore(key, c.newClient(key)) + return client.(*fasthttp.Client) //nolint:forcetypeassert } diff --git a/internal/script/http.go b/internal/script/http.go index 9c97fd9..bc48bdb 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -67,8 +67,8 @@ const httpDefaultMethod = http.MethodGet // httpMaxRedirectsLimit is the highest maxRedirects a script may ask for. const httpMaxRedirectsLimit = 100 -// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for, 1 TiB. -const httpMaxBodySizeLimit int64 = 1 << 40 +// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for. +const httpMaxBodySizeLimit int64 = types.ByteSizeLimit // Option keys accepted by the http function. const ( @@ -132,13 +132,30 @@ func parseHTTPMaxBodySize(size float64) (int64, error) { httpOptionMaxBodySize, types.NewScriptTypeError( "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), - strconv.FormatFloat(size, 'g', -1, 64), + strconv.FormatFloat(size, 'f', -1, 64), ), ) } return int64(size), nil } +// parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB". +// It can return the following errors: +// - types.ScriptHTTPOptionError +func parseHTTPMaxBodySizeString(value string) (int64, error) { + size, err := types.ParseByteSize(value) + if err != nil { + return 0, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) + } + if size > uint64(httpMaxBodySizeLimit) { + return 0, types.NewScriptHTTPOptionError( + httpOptionMaxBodySize, + types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value), + ) + } + return int64(size), nil +} + // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". // It can return the following errors: // - types.ScriptHTTPOptionError diff --git a/internal/script/js_http.go b/internal/script/js_http.go index 0a085b3..becf8cc 100644 --- a/internal/script/js_http.go +++ b/internal/script/js_http.go @@ -118,13 +118,18 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error { return err } case httpOptionMaxBodySize: - size, ok := numberValue(value) - if !ok { - return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value))) - } - var err error - if req.MaxBodySize, err = parseHTTPMaxBodySize(size); err != nil { + switch size := value.Export().(type) { + case string: + req.MaxBodySize, err = parseHTTPMaxBodySizeString(size) + default: + number, ok := numberValue(value) + if !ok { + return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", jsTypeName(value))) + } + req.MaxBodySize, err = parseHTTPMaxBodySize(number) + } + if err != nil { return err } default: diff --git a/internal/script/lua_http.go b/internal/script/lua_http.go index bab4dfb..7750c6b 100644 --- a/internal/script/lua_http.go +++ b/internal/script/lua_http.go @@ -107,12 +107,14 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error { } req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count)) case httpOptionMaxBodySize: - size, ok := v.(lua.LNumber) - if !ok { - err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String())) - return + switch size := v.(type) { + case lua.LNumber: + req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size)) + case lua.LString: + req.MaxBodySize, err = parseHTTPMaxBodySizeString(string(size)) + default: + err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", v.Type().String())) } - req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size)) default: err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) } diff --git a/internal/script/script.go b/internal/script/script.go index 07b3365..124fda0 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -171,10 +171,13 @@ func fetchURL(ctx context.Context, url string) (string, error) { return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return "", types.NewHTTPFetchError(url, err) } + if len(data) > types.RemoteFetchLimit { + return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } return string(data), nil } diff --git a/internal/types/byte_size.go b/internal/types/byte_size.go new file mode 100644 index 0000000..78ebc6a --- /dev/null +++ b/internal/types/byte_size.go @@ -0,0 +1,86 @@ +package types + +import ( + "math" + "strconv" + "strings" +) + +// ByteSizeLimit is the largest size any option may ask for, 1 TiB, less where int is 32 bit. +const ByteSizeLimit = min(1<<40, math.MaxInt) + +// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script. +const RemoteFetchLimit = 64 << 20 // 64 MiB + +// byteSizeUnits run from the largest scale down, so formatting picks the largest unit +// that fits. A bare "K" is out: the common libraries disagree on whether it counts +// 1000 or 1024 bytes. +var byteSizeUnits = []struct { + suffix string + scale uint64 +}{ + {"TiB", 1 << 40}, + {"TB", 1000 * 1000 * 1000 * 1000}, + {"GiB", 1 << 30}, + {"GB", 1000 * 1000 * 1000}, + {"MiB", 1 << 20}, + {"MB", 1000 * 1000}, + {"KiB", 1 << 10}, + {"KB", 1000}, + {"B", 1}, +} + +// ParseByteSize reads a byte count, with or without a unit suffix. +// It can return the following errors: +// - ErrByteSizeInvalid +func ParseByteSize(value string) (uint64, error) { + text := strings.TrimSpace(value) + + // The suffix is split off by index, so no case mapping can shift the digits. + digitsEnd := len(text) + for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) { + digitsEnd-- + } + + scale := uint64(1) + if suffix := text[digitsEnd:]; suffix != "" { + unitScale, ok := byteSizeScale(suffix) + if !ok { + return 0, ErrByteSizeInvalid + } + scale = unitScale + } + + size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64) + if err != nil { + return 0, ErrByteSizeInvalid + } + if size > math.MaxUint64/scale { + return 0, ErrByteSizeInvalid + } + return size * scale, nil +} + +// FormatByteSize renders a byte count so that ParseByteSize reads back the same value. +func FormatByteSize(size uint64) string { + for _, unit := range byteSizeUnits { + if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 { + return strconv.FormatUint(size/unit.scale, 10) + unit.suffix + } + } + return strconv.FormatUint(size, 10) +} + +// byteSizeScale looks up a unit suffix, ignoring case. +func byteSizeScale(suffix string) (uint64, bool) { + for _, unit := range byteSizeUnits { + if strings.EqualFold(suffix, unit.suffix) { + return unit.scale, true + } + } + return 0, false +} + +func isASCIILetter(char byte) bool { + return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') +} diff --git a/internal/types/errors.go b/internal/types/errors.go index 16b0d9e..5f2610c 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -15,6 +15,8 @@ var ( "invalid value for size, expected a whole number of bytes or a whole number with a unit: " + "B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'", ) + + ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit)) ) type FieldParseError struct { @@ -413,7 +415,7 @@ var ( 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") - ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds maxBodySize") + ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds the body limit") ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself") ) From ef5907dc0c9b3772e4034aba3561c35426455d37 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 00:55:04 +0400 Subject: [PATCH 17/40] fix: cap body limits at 1GiB and key script clients by the exact size --- docs/configuration.md | 8 +++---- docs/templating.md | 2 +- internal/sarin/scripthttp.go | 46 ++++++++++++++++++++---------------- internal/script/http.go | 18 +++++++------- internal/types/byte_size.go | 5 ++-- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 934e2e4..53cc207 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,13 +173,13 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit. -The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1TiB`, or `2147483647` on a 32-bit platform. +The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because the limit is also the most memory one response may take. **Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. -The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). +The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). ## Concurrency @@ -596,7 +596,7 @@ http(url, opts) | `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 | -| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept (the max response body config does not apply), maximum `1TiB` | +| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept, `0` accepts any size, maximum `1GiB` | Unknown options and values of the wrong type raise an error. @@ -619,7 +619,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). -- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. +- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. The limit is also the most memory one response may take, so `0` gives a server as much memory as it asks for. - **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. diff --git a/docs/templating.md b/docs/templating.md index c3ddc5a..ec98792 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -521,7 +521,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) | `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_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"` | diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 2652417..6cca8c7 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "sync" + "sync/atomic" "time" "github.com/valyala/fasthttp" @@ -21,6 +22,9 @@ const scriptHTTPDefaultTimeout = 30 * time.Second // independent of the max response body config. const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB +// scriptHTTPMaxClients bounds the client cache, since each one holds a connection pool. +const scriptHTTPMaxClients = 16 + // scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body // limit per client rather than per request. type scriptHTTPClientKey struct { @@ -33,6 +37,7 @@ type scriptHTTPClient struct { ctx context.Context //nolint:containedctx newClient func(key scriptHTTPClientKey) *fasthttp.Client clients sync.Map + clientCount atomic.Int64 defaultTimeout time.Duration } @@ -79,18 +84,13 @@ func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, } } -// scriptHTTPBodyCap rounds a limit up to a power of two, so a script that varies its -// limit cannot mint a client, and a connection pool with it, per request. -func scriptHTTPBodyCap(maxBodySize int64) int { - if maxBodySize <= 0 { +// scriptHTTPBodyLimit resolves the limit a request is read with, where a request +// without one takes the default and zero means no limit. +func scriptHTTPBodyLimit(maxBodySize *int64) int { + if maxBodySize == nil { return scriptHTTPDefaultMaxBodySize } - - size := int64(1) - for size < maxBodySize { - size <<= 1 - } - return safeInt64ToInt(size) + return safeInt64ToInt(*maxBodySize) } // dialWithDeadline dials within the request timeout and keeps it as the connection deadline, @@ -163,7 +163,8 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro timeout = c.defaultTimeout } - client := c.clientFor(r.Insecure, r.MaxBodySize) + limit := scriptHTTPBodyLimit(r.MaxBodySize) + client := c.clientFor(r.Insecure, limit) req.SetTimeout(timeout) if r.MaxRedirects > 0 { @@ -178,11 +179,7 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } - // The client caps the body at the rounded up limit, so the exact one is checked here. body := resp.Body() - if r.MaxBodySize > 0 && int64(len(body)) > r.MaxBodySize { - return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPBodyTooLarge) - } return &script.HTTPResponse{ Status: resp.StatusCode(), @@ -191,13 +188,22 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro }, nil } -// clientFor returns the client whose body limit covers maxBodySize, building it once. -func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client { - key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)} +// clientFor returns the client enforcing maxBodySize, building it once. +func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int) *fasthttp.Client { + key := scriptHTTPClientKey{insecure: insecure, maxBodySize: maxBodySize} if client, ok := c.clients.Load(key); ok { return client.(*fasthttp.Client) //nolint:forcetypeassert } - client, _ := c.clients.LoadOrStore(key, c.newClient(key)) - return client.(*fasthttp.Client) //nolint:forcetypeassert + client := c.newClient(key) + // A script that varies its limit would otherwise grow the cache without end. + if c.clientCount.Load() >= scriptHTTPMaxClients { + return client + } + + stored, loaded := c.clients.LoadOrStore(key, client) + if !loaded { + c.clientCount.Add(1) + } + return stored.(*fasthttp.Client) //nolint:forcetypeassert } diff --git a/internal/script/http.go b/internal/script/http.go index bc48bdb..aabf3f2 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -21,7 +21,7 @@ type HTTPRequest struct { Timeout time.Duration // zero means the doer's default timeout Insecure bool MaxRedirects int - MaxBodySize int64 // zero means no limit + MaxBodySize *int64 // nil takes the doer default, zero means no limit } // HTTPResponse is the response returned to a script by the http.* functions. @@ -126,9 +126,9 @@ func parseHTTPMaxRedirects(count float64) (int, error) { // parseHTTPMaxBodySize checks that the body limit is a non-negative whole number. // It can return the following errors: // - types.ScriptHTTPOptionError -func parseHTTPMaxBodySize(size float64) (int64, error) { +func parseHTTPMaxBodySize(size float64) (*int64, error) { if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) { - return 0, types.NewScriptHTTPOptionError( + return nil, types.NewScriptHTTPOptionError( httpOptionMaxBodySize, types.NewScriptTypeError( "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), @@ -136,24 +136,26 @@ func parseHTTPMaxBodySize(size float64) (int64, error) { ), ) } - return int64(size), nil + limit := int64(size) + return &limit, nil } // parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB". // It can return the following errors: // - types.ScriptHTTPOptionError -func parseHTTPMaxBodySizeString(value string) (int64, error) { +func parseHTTPMaxBodySizeString(value string) (*int64, error) { size, err := types.ParseByteSize(value) if err != nil { - return 0, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) + return nil, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) } if size > uint64(httpMaxBodySizeLimit) { - return 0, types.NewScriptHTTPOptionError( + return nil, types.NewScriptHTTPOptionError( httpOptionMaxBodySize, types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value), ) } - return int64(size), nil + limit := int64(size) + return &limit, nil } // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". diff --git a/internal/types/byte_size.go b/internal/types/byte_size.go index 78ebc6a..9dec020 100644 --- a/internal/types/byte_size.go +++ b/internal/types/byte_size.go @@ -6,8 +6,9 @@ import ( "strings" ) -// ByteSizeLimit is the largest size any option may ask for, 1 TiB, less where int is 32 bit. -const ByteSizeLimit = min(1<<40, math.MaxInt) +// ByteSizeLimit is the largest size any option may ask for. A limit is also the most +// memory one response may take, so it stays well under what a machine can hold. +const ByteSizeLimit = 1 << 30 // 1 GiB // RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script. const RemoteFetchLimit = 64 << 20 // 64 MiB From 9c7b7c26a0a504b50dde322fecf43b593ff14344 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 10:58:25 +0400 Subject: [PATCH 18/40] refactor(script): drop the per-call maxBodySize and its client cache --- docs/configuration.md | 7 +-- internal/config/file.go | 5 ++ internal/sarin/filecache.go | 5 ++ internal/sarin/helpers.go | 7 --- internal/sarin/runner.go | 2 +- internal/sarin/scripthttp.go | 105 ++++++++++++----------------------- internal/script/http.go | 40 ------------- internal/script/js_http.go | 15 ----- internal/script/lua_http.go | 9 --- internal/script/script.go | 5 ++ 10 files changed, 54 insertions(+), 146 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 53cc207..c6d869e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,13 +173,13 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit. -The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because the limit is also the most memory one response may take. +The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory. **Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. -The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). +The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well. ## Concurrency @@ -596,7 +596,6 @@ http(url, opts) | `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 | -| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept, `0` accepts any size, maximum `1GiB` | Unknown options and values of the wrong type raise an error. @@ -619,7 +618,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). -- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. The limit is also the most memory one response may take, so `0` gives a server as much memory as it asks for. +- **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch. - **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts. - **Dry run:** script requests are still sent in dry-run mode. - **Not measured:** script requests are not included in the results. diff --git a/internal/config/file.go b/internal/config/file.go index a774610..ed91b28 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -79,6 +79,11 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) { return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } + // A declared size above the limit is refused before anything is read. + if resp.ContentLength > types.RemoteFetchLimit { + return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return nil, types.NewHTTPFetchError(url, err) diff --git a/internal/sarin/filecache.go b/internal/sarin/filecache.go index d451c97..e866edd 100644 --- a/internal/sarin/filecache.go +++ b/internal/sarin/filecache.go @@ -93,6 +93,11 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) { return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } + // A declared size above the limit is refused before anything is read. + if resp.ContentLength > types.RemoteFetchLimit { + return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } + content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return nil, "", types.NewHTTPFetchError(url, err) diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go index 91c15b5..7cd6de1 100644 --- a/internal/sarin/helpers.go +++ b/internal/sarin/helpers.go @@ -34,10 +34,3 @@ func safeUint64ToInt(u uint64) int { } return int(u) } - -func safeInt64ToInt(i int64) int { - if i > math.MaxInt { - return math.MaxInt - } - return int(i) -} diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index bf7e7b3..60b72e8 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -281,7 +281,7 @@ func NewSarin( var scriptHTTPClients []*scriptHTTPClient if !scriptChain.IsEmpty() { - scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers) + scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody) if err != nil { jobsCancel() return nil, err diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 6cca8c7..f5f48c1 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -6,8 +6,6 @@ import ( "errors" "net" "net/url" - "sync" - "sync/atomic" "time" "github.com/valyala/fasthttp" @@ -18,26 +16,11 @@ import ( // scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T. const scriptHTTPDefaultTimeout = 30 * time.Second -// scriptHTTPDefaultMaxBodySize is the default body limit for script requests, -// independent of the max response body config. -const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB - -// scriptHTTPMaxClients bounds the client cache, since each one holds a connection pool. -const scriptHTTPMaxClients = 16 - -// scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body -// limit per client rather than per request. -type scriptHTTPClientKey struct { - insecure bool - maxBodySize int -} - // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { ctx context.Context //nolint:containedctx - newClient func(key scriptHTTPClientKey) *fasthttp.Client - clients sync.Map - clientCount atomic.Int64 + client *fasthttp.Client + insecureClient *fasthttp.Client defaultTimeout time.Duration } @@ -46,9 +29,9 @@ 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) { +func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) @@ -57,42 +40,42 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) if err != nil { return nil, types.NewProxyDialError(proxy.String(), err) } - clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns)) + clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)) } return clients, nil } -func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient { +func newScriptHTTPClient( + ctx context.Context, + dial fasthttp.DialFuncWithTimeout, + defaultTimeout time.Duration, + maxConns uint, + maxResponseBody uint64, +) *scriptHTTPClient { + newClient := func(insecure bool) *fasthttp.Client { + // Read and write timeouts stay unset because they would cap the per-call timeout, + // so dialWithDeadline is what bounds the TLS handshake. + return &fasthttp.Client{ + DialTimeout: dialWithDeadline(dial), + MaxConnsPerHost: safeUintToInt(maxConns), + MaxResponseBodySize: safeUint64ToInt(maxResponseBody), + TLSConfig: &tls.Config{ + InsecureSkipVerify: insecure, //nolint:gosec + }, + DisableHeaderNamesNormalizing: true, + DisablePathNormalizing: true, + NoDefaultUserAgentHeader: true, + } + } + return &scriptHTTPClient{ - ctx: ctx, - newClient: func(key scriptHTTPClientKey) *fasthttp.Client { - // Read and write timeouts stay unset because they would cap the per-call timeout, - // so dialWithDeadline is what bounds the TLS handshake. - return &fasthttp.Client{ - DialTimeout: dialWithDeadline(dial), - MaxConnsPerHost: safeUintToInt(maxConns), - MaxResponseBodySize: key.maxBodySize, - TLSConfig: &tls.Config{ - InsecureSkipVerify: key.insecure, //nolint:gosec - }, - DisableHeaderNamesNormalizing: true, - DisablePathNormalizing: true, - NoDefaultUserAgentHeader: true, - } - }, + ctx: ctx, + client: newClient(false), + insecureClient: newClient(true), defaultTimeout: defaultTimeout, } } -// scriptHTTPBodyLimit resolves the limit a request is read with, where a request -// without one takes the default and zero means no limit. -func scriptHTTPBodyLimit(maxBodySize *int64) int { - if maxBodySize == nil { - return scriptHTTPDefaultMaxBodySize - } - return safeInt64ToInt(*maxBodySize) -} - // 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 { @@ -163,8 +146,10 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro timeout = c.defaultTimeout } - limit := scriptHTTPBodyLimit(r.MaxBodySize) - client := c.clientFor(r.Insecure, limit) + client := c.client + if r.Insecure { + client = c.insecureClient + } req.SetTimeout(timeout) if r.MaxRedirects > 0 { @@ -187,23 +172,3 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro Body: string(body), }, nil } - -// clientFor returns the client enforcing maxBodySize, building it once. -func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int) *fasthttp.Client { - key := scriptHTTPClientKey{insecure: insecure, maxBodySize: maxBodySize} - if client, ok := c.clients.Load(key); ok { - return client.(*fasthttp.Client) //nolint:forcetypeassert - } - - client := c.newClient(key) - // A script that varies its limit would otherwise grow the cache without end. - if c.clientCount.Load() >= scriptHTTPMaxClients { - return client - } - - stored, loaded := c.clients.LoadOrStore(key, client) - if !loaded { - c.clientCount.Add(1) - } - return stored.(*fasthttp.Client) //nolint:forcetypeassert -} diff --git a/internal/script/http.go b/internal/script/http.go index aabf3f2..0b80b1f 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -21,7 +21,6 @@ type HTTPRequest struct { Timeout time.Duration // zero means the doer's default timeout Insecure bool MaxRedirects int - MaxBodySize *int64 // nil takes the doer default, zero means no limit } // HTTPResponse is the response returned to a script by the http.* functions. @@ -67,9 +66,6 @@ const httpDefaultMethod = http.MethodGet // httpMaxRedirectsLimit is the highest maxRedirects a script may ask for. const httpMaxRedirectsLimit = 100 -// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for. -const httpMaxBodySizeLimit int64 = types.ByteSizeLimit - // Option keys accepted by the http function. const ( httpOptionMethod = "method" @@ -80,7 +76,6 @@ const ( httpOptionTimeout = "timeout" httpOptionInsecure = "insecure" httpOptionMaxRedirects = "maxRedirects" - httpOptionMaxBodySize = "maxBodySize" ) // httpBridge connects http to the doer. Calls only work while transform runs. @@ -123,41 +118,6 @@ func parseHTTPMaxRedirects(count float64) (int, error) { return int(count), nil } -// parseHTTPMaxBodySize checks that the body limit is a non-negative whole number. -// It can return the following errors: -// - types.ScriptHTTPOptionError -func parseHTTPMaxBodySize(size float64) (*int64, error) { - if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) { - return nil, types.NewScriptHTTPOptionError( - httpOptionMaxBodySize, - types.NewScriptTypeError( - "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), - strconv.FormatFloat(size, 'f', -1, 64), - ), - ) - } - limit := int64(size) - return &limit, nil -} - -// parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB". -// It can return the following errors: -// - types.ScriptHTTPOptionError -func parseHTTPMaxBodySizeString(value string) (*int64, error) { - size, err := types.ParseByteSize(value) - if err != nil { - return nil, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) - } - if size > uint64(httpMaxBodySizeLimit) { - return nil, types.NewScriptHTTPOptionError( - httpOptionMaxBodySize, - types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value), - ) - } - limit := int64(size) - return &limit, nil -} - // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". // It can return the following errors: // - types.ScriptHTTPOptionError diff --git a/internal/script/js_http.go b/internal/script/js_http.go index becf8cc..7cdce59 100644 --- a/internal/script/js_http.go +++ b/internal/script/js_http.go @@ -117,21 +117,6 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error { if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil { return err } - case httpOptionMaxBodySize: - var err error - switch size := value.Export().(type) { - case string: - req.MaxBodySize, err = parseHTTPMaxBodySizeString(size) - default: - number, ok := numberValue(value) - if !ok { - return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", jsTypeName(value))) - } - req.MaxBodySize, err = parseHTTPMaxBodySize(number) - } - if err != nil { - return err - } default: return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) } diff --git a/internal/script/lua_http.go b/internal/script/lua_http.go index 7750c6b..a1e3a14 100644 --- a/internal/script/lua_http.go +++ b/internal/script/lua_http.go @@ -106,15 +106,6 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error { return } req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count)) - case httpOptionMaxBodySize: - switch size := v.(type) { - case lua.LNumber: - req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size)) - case lua.LString: - req.MaxBodySize, err = parseHTTPMaxBodySizeString(string(size)) - default: - err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", v.Type().String())) - } default: err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) } diff --git a/internal/script/script.go b/internal/script/script.go index 124fda0..30aaf0e 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -171,6 +171,11 @@ func fetchURL(ctx context.Context, url string) (string, error) { return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) } + // A declared size above the limit is refused before anything is read. + if resp.ContentLength > types.RemoteFetchLimit { + return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1)) if err != nil { return "", types.NewHTTPFetchError(url, err) From 9d6e4e6bf8df733349a12d9d064f19a83d4a49ce Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 22:59:42 +0400 Subject: [PATCH 19/40] chore(template): raise the remote file timeout to 30s --- docs/templating.md | 2 +- internal/sarin/runner.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/templating.md b/docs/templating.md index ec98792..66ad527 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -4,7 +4,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook > **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates. -> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. +> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file. ## Table of Contents diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 60b72e8..225aae7 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -311,7 +311,7 @@ func NewSarin( logFile: logFile, hostClients: hostClients, scriptHTTPClients: scriptHTTPClients, - fileCache: NewFileCache(time.Second * 10), + fileCache: NewFileCache(time.Second * 30), scriptChain: scriptChain, } From a327c4978b3e8dd5ac5a52251c4fedbc9e9fea57 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 23:06:22 +0400 Subject: [PATCH 20/40] fix: report one error text when a response body is too large --- docs/configuration.md | 2 +- internal/sarin/scripthttp.go | 2 +- internal/sarin/worker.go | 4 ++++ internal/types/errors.go | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c6d869e..9adbb72 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,7 +177,7 @@ The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, ` **Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` -A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. +A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `response body exceeds the body limit` and is counted like any other error. The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well. diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index f5f48c1..633ddf8 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -159,7 +159,7 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro } if err != nil { if errors.Is(err, fasthttp.ErrBodyTooLarge) { - err = types.ErrScriptHTTPBodyTooLarge + err = types.ErrResponseBodyTooLarge } return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } diff --git a/internal/sarin/worker.go b/internal/sarin/worker.go index 2d0897e..034ccc1 100644 --- a/internal/sarin/worker.go +++ b/internal/sarin/worker.go @@ -35,6 +35,10 @@ func statusCodeToString(code int) string { // responseErrorKey drops the local address, whose port would mint a stats entry per connection. func responseErrorKey(err error) string { + if errors.Is(err, fasthttp.ErrBodyTooLarge) { + return types.ErrResponseBodyTooLarge.Error() + } + message := err.Error() var opErr *net.OpError diff --git a/internal/types/errors.go b/internal/types/errors.go index 5f2610c..a6c4e4f 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -17,6 +17,8 @@ var ( ) ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit)) + + ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit") ) type FieldParseError struct { @@ -415,7 +417,6 @@ var ( 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") - ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds the body limit") ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself") ) From 17dfa160515c23472f85ebe757bdc1a4a4383bc9 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 23:22:51 +0400 Subject: [PATCH 21/40] feat(script): add res.bytes() so JavaScript can read a binary body --- docs/configuration.md | 3 +++ internal/script/js_http.go | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 9adbb72..6b3e499 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -609,9 +609,12 @@ Unknown options and values of the wrong type raise an error. | `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing | | `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing | | `json()` | JavaScript only: parses the body as JSON | +| `bytes()` | JavaScript only: the body as a `Uint8Array` | In Lua, call the helpers as `res:header("name")` or `res.header("name")`. +> **Note:** JavaScript strings hold UTF-16, so `body` replaces bytes that are not valid UTF-8 and `body.length` counts code units rather than bytes. Use `bytes()` for binary responses and for the real byte count. Lua strings keep the bytes as they arrived. + **Behavior:** - **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`). diff --git a/internal/script/js_http.go b/internal/script/js_http.go index 7cdce59..ee58c1c 100644 --- a/internal/script/js_http.go +++ b/internal/script/js_http.go @@ -159,6 +159,16 @@ func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object { } return value }) + // body is a JavaScript string, so bytes that are not valid UTF-8 reach the script + // only through this. + _ = obj.Set("bytes", func(goja.FunctionCall) goja.Value { + buffer := rt.NewArrayBuffer([]byte(resp.Body)) + value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer)) + if err != nil { + panic(err) + } + return value + }) return obj } From e0f6d4aebad36d7496a83a39295afa6218b72349 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 23:22:51 +0400 Subject: [PATCH 22/40] fix: send an idempotent request once instead of retrying it five times --- internal/sarin/client.go | 2 ++ internal/sarin/scripthttp.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index a71c06e..beaf5af 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -60,6 +60,7 @@ func NewHostClients( WriteTimeout: timeout, ReadTimeout: timeout, MaxResponseBodySize: safeUint64ToInt(maxResponseBody), + MaxIdemponentCallAttempts: 1, DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, @@ -83,6 +84,7 @@ func NewHostClients( WriteTimeout: timeout, ReadTimeout: timeout, MaxResponseBodySize: safeUint64ToInt(maxResponseBody), + MaxIdemponentCallAttempts: 1, DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 633ddf8..285b485 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -59,6 +59,8 @@ func newScriptHTTPClient( DialTimeout: dialWithDeadline(dial), MaxConnsPerHost: safeUintToInt(maxConns), MaxResponseBodySize: safeUint64ToInt(maxResponseBody), + // Retries would send one script call several times and hide it from the results. + MaxIdemponentCallAttempts: 1, TLSConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec }, From fb8443fd25ac84ddb4aa24b13ed38f3a83a001c8 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Tue, 29 Sep 2026 23:40:57 +0400 Subject: [PATCH 23/40] chore: read response headers into a 64KiB buffer instead of 4KiB --- internal/sarin/client.go | 2 ++ internal/sarin/scripthttp.go | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index beaf5af..861105e 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -61,6 +61,7 @@ func NewHostClients( ReadTimeout: timeout, MaxResponseBodySize: safeUint64ToInt(maxResponseBody), MaxIdemponentCallAttempts: 1, + ReadBufferSize: 64 << 10, // 64 KiB DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, @@ -85,6 +86,7 @@ func NewHostClients( ReadTimeout: timeout, MaxResponseBodySize: safeUint64ToInt(maxResponseBody), MaxIdemponentCallAttempts: 1, + ReadBufferSize: 64 << 10, // 64 KiB DisableHeaderNamesNormalizing: true, DisablePathNormalizing: true, NoDefaultUserAgentHeader: true, diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 285b485..614c822 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -56,11 +56,11 @@ func newScriptHTTPClient( // Read and write timeouts stay unset because they would cap the per-call timeout, // so dialWithDeadline is what bounds the TLS handshake. return &fasthttp.Client{ - DialTimeout: dialWithDeadline(dial), - MaxConnsPerHost: safeUintToInt(maxConns), - MaxResponseBodySize: safeUint64ToInt(maxResponseBody), - // Retries would send one script call several times and hide it from the results. + DialTimeout: dialWithDeadline(dial), + MaxConnsPerHost: safeUintToInt(maxConns), + MaxResponseBodySize: safeUint64ToInt(maxResponseBody), MaxIdemponentCallAttempts: 1, + ReadBufferSize: 64 << 10, // 64 KiB TLSConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec }, From 68de4156452a1a2431659ab930e9cb4f418d37cd Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 00:14:51 +0400 Subject: [PATCH 24/40] fix(config): reject unknown fields in a YAML config file --- internal/config/file.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/config/file.go b/internal/config/file.go index ed91b28..3199cf8 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "context" "fmt" "io" @@ -235,8 +236,10 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { parsedData = &configYAML{} ) - err := yaml.Unmarshal(data, &parsedData) - if err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + // The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one. + if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint return nil, types.NewUnmarshalError(err) } From 633f548e7b55fcad5cb1fe358cd52bba61af926f Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 01:07:28 +0400 Subject: [PATCH 25/40] fix(config): report YAML config errors more clearly --- internal/config/bytesize.go | 11 +-------- internal/config/config.go | 15 ++++++++---- internal/config/file.go | 46 ++++++++++++++++++++++++++++++++++++- internal/types/errors.go | 23 +++++++++++++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/internal/config/bytesize.go b/internal/config/bytesize.go index 50abbc4..f770d4a 100644 --- a/internal/config/bytesize.go +++ b/internal/config/bytesize.go @@ -30,20 +30,11 @@ func (size *byteSize) Set(value string) error { return nil } -// UnmarshalYAML accepts both a YAML integer and a string with a unit. +// UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line. func (size *byteSize) UnmarshalYAML(node *yaml.Node) error { if node.Kind != yaml.ScalarNode { return fmt.Errorf("expected a size, but got %s", node.Tag) } - if node.Tag == "!!int" { - var parsed uint64 - if err := node.Decode(&parsed); err != nil { - return types.ErrByteSizeInvalid - } - *size = byteSize(parsed) - return nil - } - return size.Set(node.Value) } diff --git a/internal/config/config.go b/internal/config/config.go index 397dede..ff81115 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -652,12 +652,19 @@ func ReadAllConfigs() *Config { for _, configFile := range append(envConfig.Files, cliConf.Files...) { fileConfig, err := parseConfigFile(configFile, 10) + + // A nested configFile fails under its own name, not the one given on the command line. + path := configFile.Path() + if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok { + path = configFileErr.Path + } + _ = utilsErr.MustHandle(err, utilsErr.OnType(func(err types.ConfigFileReadError) error { cliParser.PrintHelp() fmt.Fprint(os.Stderr, lipgloss.Sprintln( StyleYellow.Render( - fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(), + fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(), ), )) os.Exit(1) @@ -666,14 +673,14 @@ func ReadAllConfigs() *Config { utilsErr.OnType(func(err types.UnmarshalError) error { fmt.Fprint(os.Stderr, lipgloss.Sprintln( StyleYellow.Render( - fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(), + fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(), ), )) os.Exit(1) return nil }), utilsErr.OnType(func(err types.FieldParseErrors) error { - printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...) + printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...) os.Exit(1) return nil }), @@ -697,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error) configFileParser := NewConfigFileParser(configFile) fileConfig, err := configFileParser.Parse() if err != nil { - return nil, err + return nil, types.NewConfigFileError(configFile.Path(), err) } if maxDepth <= 0 { diff --git a/internal/config/file.go b/internal/config/file.go index 3199cf8..8c181d0 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -3,12 +3,14 @@ package config import ( "bytes" "context" + "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" + "reflect" "strings" "time" @@ -240,7 +242,15 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { decoder.KnownFields(true) // The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one. if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint - return nil, types.NewUnmarshalError(err) + return nil, types.NewUnmarshalError(yamlError(err)) + } + + // Decode reads one document, so anything after the first "---" would be dropped silently. + switch err := decoder.Decode(&yaml.Node{}); { + case err == nil: + return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments) + case err != io.EOF: //nolint:errorlint + return nil, types.NewUnmarshalError(yamlError(err)) } var fieldParseErrors []types.FieldParseError @@ -315,3 +325,37 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { return config, nil } + +// configYAMLTypeName is the Go type name the yaml package puts in its messages. +var configYAMLTypeName = reflect.TypeFor[configYAML]().String() + +// yamlError rewrites yaml decode errors so they name config keys instead of Go types. +func yamlError(err error) error { + var loadErrors *yaml.LoadErrors + if !errors.As(err, &loadErrors) || len(loadErrors.Errors) == 0 { + return err + } + + var builder strings.Builder + for i, loadError := range loadErrors.Errors { + if i > 0 { + builder.WriteString("\n") + } + if loadError.Mark.Line > 0 { + fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line) + } + builder.WriteString(yamlErrorMessage(loadError.Message)) + } + + return errors.New(builder.String()) +} + +// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"". +func yamlErrorMessage(message string) string { + if field, ok := strings.CutPrefix(message, "field "); ok { + if name, _, ok := strings.Cut(field, " not found in type "); ok { + return fmt.Sprintf("unknown key %q", name) + } + } + return strings.ReplaceAll(message, configYAMLTypeName, "the config file") +} diff --git a/internal/types/errors.go b/internal/types/errors.go index a6c4e4f..beaa15d 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -19,6 +19,8 @@ var ( ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit)) ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit") + + ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one") ) type FieldParseError struct { @@ -306,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string { // ======================================== Config File ======================================== +// ConfigFileError names the file a parse error came from, which nested configFile chains lose. +type ConfigFileError struct { + Path string + error error +} + +func NewConfigFileError(path string, err error) ConfigFileError { + if err == nil { + err = errNoError + } + return ConfigFileError{path, err} +} + +func (e ConfigFileError) Error() string { + return e.error.Error() +} + +func (e ConfigFileError) Unwrap() error { + return e.error +} + type ConfigFileReadError struct { error error } From 4a14d57d153d459caaaf039e84f31b534d2c6e5a Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 12:29:00 +0400 Subject: [PATCH 26/40] fix(config): reject a hidden YAML document and drop Go types from errors --- internal/config/bytesize.go | 2 +- internal/config/config.go | 2 +- internal/config/file.go | 207 +++++++++++++++++++++++++++++++----- 3 files changed, 184 insertions(+), 27 deletions(-) diff --git a/internal/config/bytesize.go b/internal/config/bytesize.go index f770d4a..4d2953d 100644 --- a/internal/config/bytesize.go +++ b/internal/config/bytesize.go @@ -33,7 +33,7 @@ func (size *byteSize) Set(value string) error { // UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line. func (size *byteSize) UnmarshalYAML(node *yaml.Node) error { if node.Kind != yaml.ScalarNode { - return fmt.Errorf("expected a size, but got %s", node.Tag) + return fmt.Errorf("expected a size, but got %s", yamlNodeName(node)) } return size.Set(node.Value) diff --git a/internal/config/config.go b/internal/config/config.go index ff81115..8406e33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -696,7 +696,7 @@ func ReadAllConfigs() *Config { // parseConfigFile recursively parses a config file and its nested files up to maxDepth levels. // Returns the merged configuration or an error if parsing fails. -// It can return the following errors: +// It can return the following errors, each wrapped in a types.ConfigFileError naming the file: // - types.ConfigFileReadError // - types.UnmarshalError // - types.FieldParseErrors diff --git a/internal/config/file.go b/internal/config/file.go index 8c181d0..dd3a902 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" "time" @@ -137,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error { *ss = slice return nil default: - return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind) + return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node)) } } @@ -158,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error { // Handle sequence of maps: [{key1: value1}, {key2: value2}] for _, item := range node.Content { if item.Kind != yaml.MappingNode { - return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind) + return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item)) } if err := kv.unmarshalMapping(item); err != nil { return err @@ -166,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error { } return nil default: - return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind) + return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node)) } } @@ -177,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error { valueNode := node.Content[i+1] if keyNode.Kind != yaml.ScalarNode { - return fmt.Errorf("expected a string key, but got %v", keyNode.Kind) + return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode)) } key := keyNode.Value @@ -189,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error { case yaml.SequenceNode: for _, v := range valueNode.Content { if v.Kind != yaml.ScalarNode { - return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind) + return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v)) } values = append(values, v.Value) } default: - return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind) + return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode)) } *kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values}) @@ -246,11 +247,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { } // Decode reads one document, so anything after the first "---" would be dropped silently. - switch err := decoder.Decode(&yaml.Node{}); { - case err == nil: - return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments) - case err != io.EOF: //nolint:errorlint - return nil, types.NewUnmarshalError(yamlError(err)) + for { + var next yaml.Node + err := decoder.Decode(&next) + if err == io.EOF { //nolint:errorlint + break + } + if err != nil { + return nil, types.NewUnmarshalError(yamlError(err)) + } + if !isEmptyDocument(&next) { + return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments) + } } var fieldParseErrors []types.FieldParseError @@ -329,33 +337,182 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { // configYAMLTypeName is the Go type name the yaml package puts in its messages. var configYAMLTypeName = reflect.TypeFor[configYAML]().String() -// yamlError rewrites yaml decode errors so they name config keys instead of Go types. +// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces. +// A null tag alone is not enough, since a user can hang it on a mapping that does hold content. +func isEmptyDocument(node *yaml.Node) bool { + if len(node.Content) == 0 { + return true + } + root := node.Content[0] + return root.Kind == yaml.ScalarNode && root.Tag == "!!null" +} + +// yamlError rewrites yaml decode errors so they read like config file errors. +// Errors from building the config arrive collected, everything earlier arrives on its own. func yamlError(err error) error { var loadErrors *yaml.LoadErrors - if !errors.As(err, &loadErrors) || len(loadErrors.Errors) == 0 { - return err + if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 { + var builder strings.Builder + for i, loadError := range loadErrors.Errors { + if i > 0 { + builder.WriteString("\n") + } + builder.WriteString(yamlErrorText(loadError)) + } + return errors.New(builder.String()) } + if loadError, ok := errors.AsType[*yaml.LoadError](err); ok { + return errors.New(yamlErrorText(loadError)) + } + + return err +} + +// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character". +// The mark is where reading gave up and the context mark starts the construct that broke. +func yamlErrorText(loadError *yaml.LoadError) string { var builder strings.Builder - for i, loadError := range loadErrors.Errors { - if i > 0 { - builder.WriteString("\n") - } - if loadError.Mark.Line > 0 { - fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line) - } - builder.WriteString(yamlErrorMessage(loadError.Message)) + if loadError.Mark.Line > 0 { + fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line) + } + if loadError.ContextMsg != "" { + builder.WriteString(loadError.ContextMsg + ": ") + } + builder.WriteString(yamlErrorMessage(loadError.Message)) + + start := loadError.ContextMark + if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line { + fmt.Fprintf(&builder, " (started at line %d)", start.Line) } - return errors.New(builder.String()) + return builder.String() +} + +// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows. +var yamlTypeNames = map[string]string{ + "!!str": "text", + "!!int": "a number", + "!!float": "a number", + "!!bool": "true or false", + "!!seq": "a list", + "!!map": "a mapping", + "!!null": "an empty value", + "!!timestamp": "a date", + "!!binary": "binary data", + "string": "text", + "bool": "true or false", + "uint": "a whole number of 0 or more", + "uint64": "a whole number of 0 or more", + "time.Duration": "a duration such as 10s", + configYAMLTypeName: "a mapping of config keys", +} + +// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number. +// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction. +func yamlNodeName(node *yaml.Node) string { + if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind { + return name + } + if node.Tag == "" { + return yamlKindName(node.Kind) + } + return yamlKindName(node.Kind) + " tagged " + node.Tag +} + +// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted. +func yamlTagKind(tag string) yaml.Kind { + switch tag { + case "!!seq": + return yaml.SequenceNode + case "!!map": + return yaml.MappingNode + default: + return yaml.ScalarNode + } +} + +// yamlKindName names a node by its shape, for when its tag says nothing a user would know. +func yamlKindName(kind yaml.Kind) string { + switch kind { + case yaml.SequenceNode: + return "a list" + case yaml.MappingNode: + return "a mapping" + case yaml.AliasNode: + return "an alias" + default: + return "a value" + } } // yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"". func yamlErrorMessage(message string) string { - if field, ok := strings.CutPrefix(message, "field "); ok { - if name, _, ok := strings.Cut(field, " not found in type "); ok { + if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok { + if name, ok := strings.CutPrefix(field, "field "); ok { return fmt.Sprintf("unknown key %q", name) } } + + // The user controls the value, so the rightmost separator is the only one that is ours. + // After "as a" comes the tag the user wrote, after "into" comes the type the key accepts. + if names, ok := strings.CutPrefix(message, "cannot construct "); ok { + at, width, tagged := -1, 0, false + for index, separator := range []string{" into ", " as a "} { + if found := strings.LastIndex(names, separator); found > at { + at, width, tagged = found, len(separator), index == 1 + } + } + if at >= 0 { + got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:]) + if tagged { + return fmt.Sprintf("%s is not %s", got, want) + } + return fmt.Sprintf("expected %s, but got %s", want, got) + } + } + return strings.ReplaceAll(message, configYAMLTypeName, "the config file") } + +// yamlWantedName names what a key accepts, which the yaml package prints as a Go type. +func yamlWantedName(text string) string { + if name, ok := yamlTypeNames[text]; ok { + return name + } + return text +} + +// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value. +// A tag the user would not know stays in the text, since without it the message reads as nonsense. +func yamlGotName(text string) string { + // A collection tag carries no backticks, so a scalar wearing one arrives glued to it. + for _, tag := range []string{"!!seq", "!!map"} { + if value, ok := strings.CutPrefix(text, tag); ok && value != "" { + return yamlValueText(value) + " tagged " + tag + } + } + + tag, value, _ := strings.Cut(text, " ") + value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`") + + name, known := yamlTypeNames[tag] + switch { + case known && value != "": + return yamlValueText(value) + case known: + return name + case value != "": + return yamlValueText(value) + " tagged " + tag + default: + return "a value tagged " + tag + } +} + +// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters. +func yamlValueText(value string) string { + if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 { + return "a value starting with " + strconv.Quote(start) + } + return strconv.Quote(value) +} From e85a6d46136ac95d2a9ca6dc9215da7f68f563e2 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 12:59:16 +0400 Subject: [PATCH 27/40] docs: drop the redirect login example, it cannot work (#235) --- docs/examples.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 9374240..ca63555 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1193,21 +1193,6 @@ js: | } ``` -**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: From bcf2ea34462571c9cb4d58441acb1eaf23269095 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 19:53:37 +0400 Subject: [PATCH 28/40] fix(script): reject async/generator JS transforms and catch throwing getters --- internal/script/js.go | 95 ++++++++++++++++++++++++++++++---------- internal/types/errors.go | 1 + 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/internal/script/js.go b/internal/script/js.go index 734b225..f47f7ea 100644 --- a/internal/script/js.go +++ b/internal/script/js.go @@ -2,6 +2,7 @@ package script import ( "errors" + "reflect" "strconv" "github.com/dop251/goja" @@ -18,6 +19,9 @@ type JsEngine struct { http httpBridge } +// promiseType is what a transform returns when it is declared async or as a generator. +var promiseType = reflect.TypeFor[*goja.Promise]() + // NewJsEngine creates a new JavaScript script engine with the given script content. // The script must define a global `transform` function that takes a request object // and returns the modified request object. @@ -58,6 +62,12 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) { return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function")) } + // A generator returns an iterator, which is an ordinary object to Go, so the + // call cannot tell it apart and the declaration is the only thing left to read. + if isGeneratorFunction(vm, transformVal) { + return nil, types.ErrScriptTransformAsync + } + engine.transform = transform return engine, nil @@ -84,6 +94,13 @@ func (e *JsEngine) Transform(req *RequestData) error { return types.NewScriptExecutionError("JavaScript", e.exceptionError(err)) } + // An async or generator transform returns a promise nothing ever awaits, and a promise + // carries none of the request fields, so the request would go out untransformed. + // ExportType reads the type alone, while Export would run every getter on the result. + if result.ExportType() == promiseType { + return types.ErrScriptTransformAsync + } + // Update RequestData from the returned object if err := e.objectToRequestData(result, req); err != nil { return types.NewScriptExecutionError("JavaScript", err) @@ -144,34 +161,40 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error { return types.ErrScriptTransformReturnObject } - // Method - if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) { - req.Method = v.String() - } + // Reading a field runs the script's own getters and toString, which throw as a Go + // panic outside goja's execution context, so the whole read runs inside Try. + if thrown := e.runtime.Try(func() { + // Method + if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + req.Method = v.String() + } - // Path - if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) { - req.Path = v.String() - } + // Path + if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + req.Path = v.String() + } - // Body - if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) { - req.Body = v.String() - } + // Body + if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + req.Body = v.String() + } - // Headers - if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { - req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime)) - } + // 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)) - } + // 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)) + // Cookies + if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime)) + } + }); thrown != nil { + return e.exceptionError(thrown) } return nil @@ -245,3 +268,29 @@ func jsStringValue(v goja.Value) (string, bool) { } return v.String(), true } + +// isGeneratorFunction reports whether a function is declared with a star. +// The name comes from the script's own prototype chain, so it is a hint, not a guarantee. +// Every read here can run a script getter, so the whole walk stays inside Try. +func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool { + var name string + thrown := runtime.Try(func() { + obj := value.ToObject(runtime) + if obj == nil { + return + } + constructor := obj.Get("constructor") + if constructor == nil || goja.IsUndefined(constructor) || goja.IsNull(constructor) { + return + } + constructorObj := constructor.ToObject(runtime) + if constructorObj == nil { + return + } + if value := constructorObj.Get("name"); value != nil { + name = value.String() + } + }) + + return thrown == nil && name == "GeneratorFunction" +} diff --git a/internal/types/errors.go b/internal/types/errors.go index beaa15d..9fdb522 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -434,6 +434,7 @@ var ( ErrScriptSourceEmpty = errors.New("script source cannot be empty after @") ErrScriptTransformMissing = errors.New("script must define a global 'transform' function") ErrScriptTransformReturnObject = errors.New("transform function must return an object") + ErrScriptTransformAsync = errors.New("transform function must be synchronous, async and generator functions are not supported") ErrScriptURLNoHost = errors.New("script URL must have a host") ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running") ErrScriptHTTPUnavailable = errors.New("http client is not available") From 9e773a4e6cc590cdb36ee8c397031dab3ace9962 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 30 Sep 2026 21:18:50 +0400 Subject: [PATCH 29/40] fix(script): reject a non-object headers, params or cookies in JavaScript --- internal/script/js.go | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/internal/script/js.go b/internal/script/js.go index f47f7ea..2c4274d 100644 --- a/internal/script/js.go +++ b/internal/script/js.go @@ -2,6 +2,7 @@ package script import ( "errors" + "fmt" "reflect" "strconv" @@ -46,8 +47,7 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) { } // Execute the script to define the transform function - _, err := vm.RunString(scriptContent) - if err != nil { + if _, err := vm.RunString(scriptContent); err != nil { return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err)) } @@ -163,6 +163,7 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error { // Reading a field runs the script's own getters and toString, which throw as a Go // panic outside goja's execution context, so the whole read runs inside Try. + var fieldErr error if thrown := e.runtime.Try(func() { // Method if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { @@ -181,23 +182,49 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error { // Headers if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { - req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime)) + values, err := objectField("headers", v) + if err != nil { + fieldErr = err + return + } + req.Headers = e.objectToStringSliceMap(values) } // Params if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { - req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime)) + values, err := objectField("params", v) + if err != nil { + fieldErr = err + return + } + req.Params = e.objectToStringSliceMap(values) } // Cookies if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { - req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime)) + values, err := objectField("cookies", v) + if err != nil { + fieldErr = err + return + } + req.Cookies = e.objectToStringSliceMap(values) } }); thrown != nil { return e.exceptionError(thrown) } - return nil + return fieldErr +} + +// objectField rejects anything that is not a plain object, since reading keys off an +// array or a boxed string yields headers named after indexes and off a Date yields none. +// A Map, a Set and a typed array still pass, so the field is emptied rather than refused. +func objectField(name string, value goja.Value) (*goja.Object, error) { + obj, ok := value.(*goja.Object) + if !ok || obj.ClassName() != "Object" { + return nil, fmt.Errorf("%s must be an object", name) + } + return obj, nil } // stringSliceToArray converts a Go []string to a JavaScript array. @@ -270,8 +297,8 @@ func jsStringValue(v goja.Value) (string, bool) { } // isGeneratorFunction reports whether a function is declared with a star. -// The name comes from the script's own prototype chain, so it is a hint, not a guarantee. -// Every read here can run a script getter, so the whole walk stays inside Try. +// The name comes from the script's own prototype chain, so a script can falsify it either way. +// Reading it can run a script getter, so the whole walk stays inside Try. func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool { var name string thrown := runtime.Try(func() { From 847c4b39ce676e7314646d449aca9d35f17bfe63 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 1 Oct 2026 21:17:43 +0400 Subject: [PATCH 30/40] fix(script): decode a compressed http() response body --- docs/configuration.md | 3 +++ internal/sarin/scripthttp.go | 52 +++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6b3e499..916e025 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,6 +181,8 @@ A server can declare a `Content-Length` far larger than it sends, and the body i The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well. +A compressed response in a script is decoded first, so the limit covers the decoded size as well as the bytes that arrived. Decoding holds both at once, so a brotli or zstd body peaks at over ten times the limit. Ratios are unbounded, and 106 bytes of brotli can hold 64MiB, so with `0` a tiny download can still exhaust memory. + ## Concurrency Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`. @@ -622,6 +624,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). - **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch. +- **Compressed responses:** sarin asks for no encoding, so this only applies when a script sends `Accept-Encoding` itself or the server compresses unasked. A `gzip`, `deflate`, `br` or `zstd` body is then decoded before the script sees it, and `Content-Encoding` and `Content-Length` are both dropped from `headers` because neither describes what the script receives. Anything else is left raw with its header intact: an empty body, `identity`, a `Content-Encoding` sarin does not recognise, and two or more codings at once. A body that fails to decode raises an error the script can catch, though `br` carries no checksum, so damage to a brotli body can go unnoticed. - **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts. - **Dry run:** script requests are still sent in dry-run mode. - **Not measured:** script requests are not included in the results. diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 614c822..c7f791f 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -1,6 +1,7 @@ package sarin import ( + "compress/zlib" "context" "crypto/tls" "errors" @@ -18,10 +19,11 @@ const scriptHTTPDefaultTimeout = 30 * time.Second // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { - ctx context.Context //nolint:containedctx - client *fasthttp.Client - insecureClient *fasthttp.Client - defaultTimeout time.Duration + ctx context.Context //nolint:containedctx + client *fasthttp.Client + insecureClient *fasthttp.Client + defaultTimeout time.Duration + maxResponseBody int } var _ script.HTTPDoer = (*scriptHTTPClient)(nil) @@ -71,10 +73,11 @@ func newScriptHTTPClient( } return &scriptHTTPClient{ - ctx: ctx, - client: newClient(false), - insecureClient: newClient(true), - defaultTimeout: defaultTimeout, + ctx: ctx, + client: newClient(false), + insecureClient: newClient(true), + defaultTimeout: defaultTimeout, + maxResponseBody: safeUint64ToInt(maxResponseBody), } } @@ -166,7 +169,10 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } - body := resp.Body() + body, err := decodeResponseBody(resp, c.maxResponseBody) + if err != nil { + return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) + } return &script.HTTPResponse{ Status: resp.StatusCode(), @@ -174,3 +180,31 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro Body: string(body), }, nil } + +// decodeResponseBody decodes a body the server compressed, since scripts read it as text. +// The limit covers the decoded size, which is what a compression bomb inflates to. +// An empty body is left alone because the decoders reject zero bytes, and a HEAD or a 304 +// carries the encoding with no body. A coding fasthttp does not decode, and the bare +// deflate stream some servers send without a zlib header, are left raw for the script. +func decodeResponseBody(resp *fasthttp.Response, maxResponseBody int) ([]byte, error) { + if len(resp.Header.ContentEncoding()) == 0 || len(resp.Body()) == 0 { + return resp.Body(), nil + } + + decoded, err := resp.BodyUncompressedWithLimit(maxResponseBody) + if errors.Is(err, fasthttp.ErrContentEncodingUnsupported) || errors.Is(err, zlib.ErrHeader) { + return resp.Body(), nil + } + if err != nil { + if errors.Is(err, fasthttp.ErrBodyTooLarge) { + err = types.ErrResponseBodyTooLarge + } + return nil, err + } + + // Neither header describes the decoded body, so both go, as net/http does on a gunzip. + resp.Header.SetContentEncoding("") + resp.Header.Del(fasthttp.HeaderContentLength) + + return decoded, nil +} From cd0fa453c27f5b10c4771f3cf96c4ebd878d1ea7 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 1 Oct 2026 22:13:29 +0400 Subject: [PATCH 31/40] fix(script): bound how deep json.encode walks a Lua table --- internal/script/lua_json.go | 11 +++++++++++ internal/types/errors.go | 1 + 2 files changed, 12 insertions(+) diff --git a/internal/script/lua_json.go b/internal/script/lua_json.go index bdc2ba2..d9da6c9 100644 --- a/internal/script/lua_json.go +++ b/internal/script/lua_json.go @@ -45,9 +45,14 @@ func luaJSONDecode(state *lua.LState) int { return 1 } +// luaJSONMaxDepth matches the depth encoding/json accepts, so a table a script +// decoded always encodes again, and it stays far below what overflows the Go stack. +const luaJSONMaxDepth = 10000 + // luaToGo converts a Lua value to a JSON-encodable Go value. // It can return the following errors: // - types.ErrScriptJSONCycle +// - types.ErrScriptJSONDepth // - types.ScriptTypeError func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) { switch v := value.(type) { @@ -63,6 +68,12 @@ func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) { if visiting[v] { return nil, types.ErrScriptJSONCycle } + // visiting holds the tables on the current path, so its size is the depth. + // A table millions of levels deep overflows the Go stack, which is a fatal + // error no pcall can catch, so the descent is bounded well before that. + if len(visiting) >= luaJSONMaxDepth { + return nil, types.ErrScriptJSONDepth + } visiting[v] = true defer delete(visiting, v) diff --git a/internal/types/errors.go b/internal/types/errors.go index 9fdb522..05c2953 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -442,6 +442,7 @@ var ( ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL") ErrScriptHTTPUnknownOption = errors.New("unknown option") ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself") + ErrScriptJSONDepth = errors.New("cannot encode a table nested too deeply") ) type ScriptTypeError struct { From bea48eeab0649f8f41d031a88aec4a424ab0a3c6 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 1 Oct 2026 22:43:24 +0400 Subject: [PATCH 32/40] docs: say that a dry run still sends a script's own requests --- docs/configuration.md | 2 ++ internal/config/cli.go | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 916e025..dcb3f88 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,6 +234,8 @@ Using `none` disables output and reduces memory usage since response statistics Generate requests without sending them. Useful for testing templates. +A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back. + ## Insecure Skip TLS certificate verification. diff --git a/internal/config/cli.go b/internal/config/cli.go index 67e665d..52f811b 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -31,7 +31,7 @@ Flags: -w, -log-file string Write runtime logs to this file instead of the terminal/stderr -p, -progress string Progress display (possible values: bar, none) (default '%v') -o, -output string Output format (possible values: table, json, yaml, none) (default '%v') - -z, -dry-run bool Run without sending requests (default %v) + -z, -dry-run bool Run without sending the generated requests (default %v) Request Config: -U, -url string Target URL for the request @@ -145,8 +145,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)") flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)") - flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests") - flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests") + flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending the generated requests") + flagSet.BoolVar(&dryRun, "z", false, "Run without sending the generated requests") // Request config flagSet.StringVar(&urlInput, "url", "", "Target URL for the request") From a1e4c152852a43e9b0d4ae33e22c059eba02beba Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Fri, 2 Oct 2026 00:12:50 +0400 Subject: [PATCH 33/40] docs: say what the dry run duration measures --- docs/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index dcb3f88..d883b8d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -236,6 +236,8 @@ Generate requests without sending them. Useful for testing templates. A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back. +The duration in the results is how long generating each request took, templates and scripts included, which is a different measurement from the response time a real run reports. + ## Insecure Skip TLS certificate verification. From 51e8f2a57f506e84cccdc922f3831b8ac0c0aa26 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Fri, 2 Oct 2026 01:13:25 +0400 Subject: [PATCH 34/40] fix(script): match the main request default for the script http timeout --- docs/configuration.md | 2 +- internal/sarin/scripthttp.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d883b8d..573da7b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -599,7 +599,7 @@ http(url, opts) | `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 | +| `timeout` | duration string (`"500ms"`) | `10s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed | | `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) | | `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 | diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index c7f791f..daba97c 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -14,8 +14,8 @@ import ( "go.aykhans.me/sarin/internal/types" ) -// scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T. -const scriptHTTPDefaultTimeout = 30 * time.Second +// scriptHTTPDefaultTimeout is the default timeout for script requests. +const scriptHTTPDefaultTimeout = 10 * time.Second // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { From ab3b458f1562ce79f52b3adcd100c92729cf908b Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Fri, 2 Oct 2026 02:05:49 +0400 Subject: [PATCH 35/40] docs: fix statements that did not match the code --- docs/configuration.md | 4 ++-- docs/examples.md | 7 +++---- docs/templating.md | 9 ++++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 573da7b..29ef4c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -203,7 +203,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`. -- `error`: errors that occur while generating or sending a request +- `error`: errors that occur while generating a request. A request that fails to send is counted in the results instead - `info`: every completed response Leave empty to disable logging entirely. @@ -236,7 +236,7 @@ Generate requests without sending them. Useful for testing templates. A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back. -The duration in the results is how long generating each request took, templates and scripts included, which is a different measurement from the response time a real run reports. +The duration in the results is how long generating each request took, templates and scripts included, so it is a different measurement from the response time a real run reports. A request with neither renders once before the run and reports `0s`. ## Insecure diff --git a/docs/examples.md b/docs/examples.md index ca63555..34a255f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -857,7 +857,7 @@ progress: none ## Runtime Logging -`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`. +`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers errors raised while generating a request, `info` covers every completed response (status, duration, headers, body). A request that fails to send is counted in the results rather than logged. Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`. **Log responses and errors:** @@ -883,9 +883,8 @@ sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log ```yaml url: http://example.com requests: 1000 -concurrency: 10 -logLevel: info,error -logFile: ./run.log +logLevel: info +output: json ``` diff --git a/docs/templating.md b/docs/templating.md index 66ad527..ecee0c8 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -2,7 +2,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. -> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates. +> **Note:** Templating in URL host and scheme is not supported. Only the path and query string of the URL can contain templates. > **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file. @@ -29,6 +29,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook - [Generate](#generate) - [Auth](#auth) - [Address](#address) + - [Airline](#airline) - [Game](#game) - [Beer](#beer) - [Car](#car) @@ -151,7 +152,7 @@ body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}' | ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- | | `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` | | `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` | -| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00Z"` | +| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00+04:00"` (local time) | | `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` | ### Crypto Functions @@ -165,6 +166,8 @@ body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}' ### Body Functions +> **Note:** These are available in `body` only, not in the URL, headers, params, cookies or values. + | Function | Description | Example | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` | @@ -387,7 +390,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) | Function | Description | Example | | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | `fakeit_Username` | Username | `"Daniel1364"` | -| `fakeit_Password(upper bool, lower bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` | +| `fakeit_Password(lower bool, upper bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` | ### Address From 23b3d7b1ccccc2f7ae4c0c2c1803d0f40fc89c93 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 4 Oct 2026 15:51:50 +0400 Subject: [PATCH 36/40] fix: dial dual-stack only for IPv6-only hosts --- internal/sarin/client.go | 47 +++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index 861105e..6136be3 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -19,6 +19,37 @@ import ( // clientIndexGenerator returns the index of the host client (and proxy) to use next. type clientIndexGenerator func() int +// dualStackLookupTimeout bounds the dial-policy lookup. A timeout falls back to dual-stack. +const dualStackLookupTimeout = 5 * time.Second + +// needsDualStack reports whether the host is IPv6-only. Otherwise IPv4 is used, as mixing families is slower. +func needsDualStack(ctx context.Context, host string, timeout time.Duration) bool { + name := host + if h, _, err := net.SplitHostPort(host); err == nil { + name = h + } + + if ip := net.ParseIP(name); ip != nil { + return ip.To4() == nil + } + + resolveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ips, err := net.DefaultResolver.LookupIP(resolveCtx, "ip", name) + if err != nil { + // Leave it on so the dial reports the real error rather than "no dns entries". + return true + } + + for _, ip := range ips { + if ip.To4() != nil { + return false + } + } + return true +} + // NewHostClients creates a list of fasthttp.HostClient instances for the given proxies. // If no proxies are provided, a single client without a proxy is returned. // It can return the following errors: @@ -75,7 +106,7 @@ func NewHostClients( client := &fasthttp.HostClient{ MaxConns: safeUintToInt(maxConns), IsTLS: isTLS, - DialDualStack: true, + DialDualStack: needsDualStack(ctx, requestURL.Host, dualStackLookupTimeout), TLSConfig: &tls.Config{ InsecureSkipVerify: skipVerify, //nolint:gosec }, @@ -120,9 +151,9 @@ func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fastht case "socks5h": return fasthttpSocksDialer(ctx, proxyURL, false) case "http": - return fasthttpConnectDialer(proxyURL, false), nil + return fasthttpConnectDialer(ctx, proxyURL, false), nil case "https": - return fasthttpConnectDialer(proxyURL, true), nil + return fasthttpConnectDialer(ctx, proxyURL, true), nil default: return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme) } @@ -231,7 +262,7 @@ func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ( // // The returned dial function can return the following errors: // - types.ProxyDialError -func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout { +func fasthttpConnectDialer(ctx context.Context, proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout { defaultPort := "80" if useTLS { defaultPort = "443" @@ -253,10 +284,16 @@ func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWith proxyStr := proxyURL.String() + // Decided here rather than per dial, so the lookup never eats into a dial's timeout. + dial := fasthttp.DialTimeout + if needsDualStack(ctx, proxyAddr, dualStackLookupTimeout) { + dial = fasthttp.DialDualStackTimeout + } + return func(addr string, timeout time.Duration) (net.Conn, error) { // Establish TCP connection to proxy with timeout start := time.Now() - conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout) + conn, err := dial(proxyAddr, timeout) if err != nil { return nil, types.NewProxyDialError(proxyStr, err) } From b7c589de531c98fbf5fda85e0a2be1bc06483738 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 4 Oct 2026 21:50:18 +0400 Subject: [PATCH 37/40] fix(client): prefer IPv4 and fall back to IPv6 instead of dialing both --- internal/sarin/client.go | 111 +++++++++++++++++++++++++---------- internal/sarin/scripthttp.go | 2 +- 2 files changed, 80 insertions(+), 33 deletions(-) diff --git a/internal/sarin/client.go b/internal/sarin/client.go index 6136be3..19f12de 100644 --- a/internal/sarin/client.go +++ b/internal/sarin/client.go @@ -19,35 +19,88 @@ import ( // clientIndexGenerator returns the index of the host client (and proxy) to use next. type clientIndexGenerator func() int -// dualStackLookupTimeout bounds the dial-policy lookup. A timeout falls back to dual-stack. -const dualStackLookupTimeout = 5 * time.Second +// preferIPv4Resolver hides a host's IPv6 addresses whenever it also has IPv4 ones. +type preferIPv4Resolver struct{} -// needsDualStack reports whether the host is IPv6-only. Otherwise IPv4 is used, as mixing families is slower. -func needsDualStack(ctx context.Context, host string, timeout time.Duration) bool { - name := host - if h, _, err := net.SplitHostPort(host); err == nil { - name = h - } +func (preferIPv4Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) { + return lookupPreferred(ctx, host, true) +} - if ip := net.ParseIP(name); ip != nil { - return ip.To4() == nil - } +// preferIPv6Resolver is the mirror of preferIPv4Resolver, used for the fallback dial. +type preferIPv6Resolver struct{} - resolveCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() +func (preferIPv6Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) { + return lookupPreferred(ctx, host, false) +} - ips, err := net.DefaultResolver.LookupIP(resolveCtx, "ip", name) +// lookupPreferred resolves host and keeps only the wanted family, unless that family is absent. +func lookupPreferred(ctx context.Context, host string, wantIPv4 bool) ([]net.IPAddr, error) { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { - // Leave it on so the dial reports the real error rather than "no dns entries". - return true + return nil, err } + preferred := make([]net.IPAddr, 0, len(ips)) for _, ip := range ips { - if ip.To4() != nil { - return false + if (ip.IP.To4() != nil) == wantIPv4 { + preferred = append(preferred, ip) } } - return true + if len(preferred) > 0 { + return preferred, nil + } + return ips, nil +} + +// preferIPv4DialTimeoutShare leaves half the timeout for the IPv6 retry. +const preferIPv4DialTimeoutShare = 2 + +// preferIPv4Dialer dials over IPv4 and retries over IPv6 alone when that fails. +type preferIPv4Dialer struct { + ipv4 *fasthttp.TCPDialer + ipv6 *fasthttp.TCPDialer +} + +// dialerConcurrency matches the limit on fasthttp own package-level dialer. +const dialerConcurrency = 1000 + +// dialer is shared so all clients use one DNS cache. +var dialer = &preferIPv4Dialer{ + ipv4: &fasthttp.TCPDialer{Resolver: preferIPv4Resolver{}, Concurrency: dialerConcurrency}, + ipv6: &fasthttp.TCPDialer{Resolver: preferIPv6Resolver{}, Concurrency: dialerConcurrency}, +} + +// DialTimeout dials addr, which must carry a port. +func (d *preferIPv4Dialer) DialTimeout(addr string, timeout time.Duration) (net.Conn, error) { + // fasthttp passes 0 from its pooled dial path. + if timeout <= 0 { + timeout = fasthttp.DefaultDialTimeout + } + deadline := time.Now().Add(timeout) + + conn, err := d.ipv4.DialDualStackTimeout(addr, timeout/preferIPv4DialTimeoutShare) + if err == nil { + return conn, nil + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, err + } + + conn, fallbackErr := d.ipv6.DialDualStackTimeout(addr, remaining) + if fallbackErr != nil { + // Report the IPv4 error, not the fallback one. + return nil, err + } + return conn, nil +} + +// dialFunc fills in the default port, which fasthttp skips for a custom dial func. +func (d *preferIPv4Dialer) dialFunc(isTLS bool) fasthttp.DialFuncWithTimeout { + return func(addr string, timeout time.Duration) (net.Conn, error) { + return d.DialTimeout(fasthttp.AddMissingPort(addr, isTLS), timeout) + } } // NewHostClients creates a list of fasthttp.HostClient instances for the given proxies. @@ -104,9 +157,9 @@ func NewHostClients( } client := &fasthttp.HostClient{ - MaxConns: safeUintToInt(maxConns), - IsTLS: isTLS, - DialDualStack: needsDualStack(ctx, requestURL.Host, dualStackLookupTimeout), + MaxConns: safeUintToInt(maxConns), + IsTLS: isTLS, + DialTimeout: dialer.dialFunc(isTLS), TLSConfig: &tls.Config{ InsecureSkipVerify: skipVerify, //nolint:gosec }, @@ -151,9 +204,9 @@ func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fastht case "socks5h": return fasthttpSocksDialer(ctx, proxyURL, false) case "http": - return fasthttpConnectDialer(ctx, proxyURL, false), nil + return fasthttpConnectDialer(proxyURL, false), nil case "https": - return fasthttpConnectDialer(ctx, proxyURL, true), nil + return fasthttpConnectDialer(proxyURL, true), nil default: return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme) } @@ -262,7 +315,7 @@ func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ( // // The returned dial function can return the following errors: // - types.ProxyDialError -func fasthttpConnectDialer(ctx context.Context, proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout { +func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout { defaultPort := "80" if useTLS { defaultPort = "443" @@ -284,16 +337,10 @@ func fasthttpConnectDialer(ctx context.Context, proxyURL *url.URL, useTLS bool) proxyStr := proxyURL.String() - // Decided here rather than per dial, so the lookup never eats into a dial's timeout. - dial := fasthttp.DialTimeout - if needsDualStack(ctx, proxyAddr, dualStackLookupTimeout) { - dial = fasthttp.DialDualStackTimeout - } - return func(addr string, timeout time.Duration) (net.Conn, error) { // Establish TCP connection to proxy with timeout start := time.Now() - conn, err := dial(proxyAddr, timeout) + conn, err := dialer.DialTimeout(proxyAddr, timeout) if err != nil { return nil, types.NewProxyDialError(proxyStr, err) } diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index daba97c..e9ddd4b 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -33,7 +33,7 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil) // - types.ProxyDialError func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) { if len(proxies) == 0 { - return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil + return []*scriptHTTPClient{newScriptHTTPClient(ctx, dialer.DialTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil } clients := make([]*scriptHTTPClient, 0, len(proxies)) From 824f03452ec944368bdd8176dbe13baceecf098b Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 4 Oct 2026 21:58:53 +0400 Subject: [PATCH 38/40] chore(deps): update dependencies --- go.mod | 20 ++++++++++---------- go.sum | 42 ++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index c38bf69..003a6ca 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.27.1 require ( charm.land/bubbles/v2 v2.2.1 - charm.land/bubbletea/v2 v2.0.9 + charm.land/bubbletea/v2 v2.0.10 charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 - github.com/brianvoe/gofakeit/v7 v7.17.0 + github.com/brianvoe/gofakeit/v7 v7.17.1 github.com/charmbracelet/x/term v0.2.2 - github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad + github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913 github.com/joho/godotenv v1.5.1 github.com/valyala/fasthttp v1.74.0 github.com/yuin/gopher-lua v1.1.2 @@ -23,28 +23,28 @@ require ( 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-20260910203606-6c9e17dc7a16 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 // indirect + github.com/charmbracelet/x/exp/slice v0.1.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2/v2 v2.8.0 // indirect + github.com/dlclark/regexp2/v2 v2.8.2 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect + github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.30 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/molecule-man/go-brrr v1.1.0 // indirect + github.com/molecule-man/go-brrr v1.2.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/xo/terminfo v1.2.0 // indirect github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/sync v0.23.0 // indirect diff --git a/go.sum b/go.sum index a13f97b..1cfe767 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= @@ -18,20 +18,20 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ 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.17.0 h1:usvHa73vmAjC1JmX4dpZAm1eQkR0riXaxvilScRTFb0= -github.com/brianvoe/gofakeit/v7 v7.17.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/brianvoe/gofakeit/v7 v7.17.1 h1:50FLBhTGVJQaj6ysRUu0it8wCdYO2uGM9VfuxI+csEc= +github.com/brianvoe/gofakeit/v7 v7.17.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/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/ultraviolet v0.0.0-20261001125412-878653296cfd h1:W0HOPGkwRaAmV20plE/f+Rk6Xn+IdR8G0qHsGXcOnF8= +github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/slice v0.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/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og= +github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -44,32 +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.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/dlclark/regexp2/v2 v2.8.2 h1:uTTe+cxADL4be+dv4DZwsBIEPCQ6RHPlnVuKrtLqSx0= +github.com/dlclark/regexp2/v2 v2.8.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913 h1:0haZHQSJqA4qFZvnKIRgekXrPo2sQ2oRvFemiNHhcp0= +github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913/go.mod h1:GrCNKUrVplXZj0juFue0Cj2Xt/eL2Nuu2+y3UTanEo8= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY= -github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d h1:kZK3FQM86gr3cgJHMP7rPn8A3Z75mt+ZaVBoqMlpPF0= +github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/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/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= +github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -82,8 +82,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= @@ -94,8 +94,6 @@ 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.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.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= From 95f55e1056e0339430d9e8a3d3090ab08bc0de1b Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 4 Oct 2026 21:59:36 +0400 Subject: [PATCH 39/40] nix: bump package version to 1.5.0 --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index bd6b01e..a6ca223 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ (buildGoModule.override { go = go_1_27; }) (finalAttrs: { pname = "sarin"; - version = "1.4.4"; # bump per release + version = "1.5.0"; # bump per release src = lib.cleanSource ../.; From 3e01ce47cc50fb40fae95902d7d605ba1f4241c8 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 4 Oct 2026 22:04:01 +0400 Subject: [PATCH 40/40] nix: update vendorHash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index a6ca223..2cc962e 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -12,7 +12,7 @@ src = lib.cleanSource ../.; - vendorHash = "sha256-KUxzFDxYbKvKASMze+f7jxfpCQbs01ezeG6a1VboHxw="; + vendorHash = "sha256-SpMOMmDa7llS8wbCDf6QJbWtZ/s8RgzMvVP1xrY934s="; subPackages = [ "cmd/cli" ];