Compare commits

...
135 Commits
Author SHA1 Message Date
aykhans a58ef60316 Merge pull request #232 from aykhans/feat/script-http
feat: add http function to script engines
2026-10-04 22:12:38 +04:00
aykhans 5b11ef2933 Merge remote-tracking branch 'origin/main' into feat/script-http
# Conflicts:
#	nix/package.nix
2026-10-04 22:10:02 +04:00
aykhans 3e01ce47cc nix: update vendorHash 2026-10-04 22:04:01 +04:00
aykhans 95f55e1056 nix: bump package version to 1.5.0 2026-10-04 21:59:36 +04:00
aykhans 824f03452e chore(deps): update dependencies 2026-10-04 21:58:53 +04:00
aykhans b7c589de53 fix(client): prefer IPv4 and fall back to IPv6 instead of dialing both 2026-10-04 21:50:18 +04:00
aykhans 23b3d7b1cc fix: dial dual-stack only for IPv6-only hosts 2026-10-04 15:51:50 +04:00
aykhans ab3b458f15 docs: fix statements that did not match the code 2026-10-02 02:05:49 +04:00
aykhans 51e8f2a57f fix(script): match the main request default for the script http timeout 2026-10-02 01:13:25 +04:00
aykhans a1e4c15285 docs: say what the dry run duration measures 2026-10-02 00:12:50 +04:00
aykhans bea48eeab0 docs: say that a dry run still sends a script's own requests 2026-10-01 22:43:24 +04:00
aykhans cd0fa453c2 fix(script): bound how deep json.encode walks a Lua table 2026-10-01 22:13:29 +04:00
aykhans 847c4b39ce fix(script): decode a compressed http() response body 2026-10-01 21:17:43 +04:00
aykhans 9e773a4e6c fix(script): reject a non-object headers, params or cookies in JavaScript 2026-09-30 21:18:50 +04:00
aykhans bcf2ea3446 fix(script): reject async/generator JS transforms and catch throwing getters 2026-09-30 19:53:37 +04:00
aykhans e85a6d4613 docs: drop the redirect login example, it cannot work (#235) 2026-09-30 12:59:16 +04:00
aykhans 4a14d57d15 fix(config): reject a hidden YAML document and drop Go types from errors 2026-09-30 12:29:00 +04:00
aykhans 633f548e7b fix(config): report YAML config errors more clearly 2026-09-30 01:07:28 +04:00
aykhans 68de415645 fix(config): reject unknown fields in a YAML config file 2026-09-30 00:14:51 +04:00
aykhans fb8443fd25 chore: read response headers into a 64KiB buffer instead of 4KiB 2026-09-29 23:40:57 +04:00
aykhans e0f6d4aeba fix: send an idempotent request once instead of retrying it five times 2026-09-29 23:22:51 +04:00
aykhans 17dfa16051 feat(script): add res.bytes() so JavaScript can read a binary body 2026-09-29 23:22:51 +04:00
aykhans a327c4978b fix: report one error text when a response body is too large 2026-09-29 23:06:22 +04:00
aykhans 9d6e4e6bf8 chore(template): raise the remote file timeout to 30s 2026-09-29 22:59:42 +04:00
aykhans 9c7b7c26a0 refactor(script): drop the per-call maxBodySize and its client cache 2026-09-29 10:58:25 +04:00
aykhans ef5907dc0c fix: cap body limits at 1GiB and key script clients by the exact size 2026-09-29 00:55:04 +04:00
aykhans 1956eb1c44 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.
2026-09-29 00:41:48 +04:00
aykhans f1d91e3d77 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().
2026-09-28 22:49:39 +04:00
aykhans 3d535d89e3 Merge pull request #234 from aykhans/dependabot/go_modules/charm.land/bubbletea/v2-2.0.10
build(deps): bump charm.land/bubbletea/v2 from 2.0.9 to 2.0.10
2026-09-28 11:09:28 +04:00
github-actions[bot] 3a23b1b370 fix(nix): update vendorHash [dependabot skip] 2026-09-28 00:17:06 +00:00
dependabot[bot] a37885a308 build(deps): bump charm.land/bubbletea/v2 from 2.0.9 to 2.0.10
---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 00:16:18 +00:00
aykhans 412a02fea3 chore: bump golangci-lint to v2.14.0 2026-09-27 01:05:07 +04:00
aykhans 8da05bb10c fix(stats): key generation failures by source and bound the key count 2026-09-27 01:03:04 +04:00
aykhans c32949c8e0 fix(script): convert non-string header, param and cookie values to text 2026-09-26 01:17:49 +04:00
aykhans a30c862936 fix(script): bind script HTTP requests to the run context 2026-09-25 22:34:20 +04:00
aykhans 5d91141f80 fix(script): stringify thrown JavaScript values through Runtime.Try 2026-09-24 22:34:59 +04:00
aykhans 878ca6dfbd fix(script): bound maxRedirects to 100 and reject out of range values 2026-09-24 19:08:58 +04:00
aykhans 9de1d5ac93 Merge pull request #233 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.17.1
build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.17.0 to 7.17.1
2026-09-23 10:44:38 +04:00
github-actions[bot] 6fc17d1e10 fix(nix): update vendorHash [dependabot skip] 2026-09-23 00:13:47 +00:00
dependabot[bot] 4518895ab2 build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.17.0 to 7.17.1
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.17.0 to 7.17.1.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.17.0...v7.17.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 00:15:38 +00:00
aykhans f42c4c70bf Merge pull request #218 from aykhans/ci/verify-package-version
ci: gate releases on the nix/package.nix version, bump to 1.4.2
2026-08-18 13:06:58 +04:00
aykhans f3f25e3ba1 chore: bump package version to 1.4.2 2026-08-18 13:03:22 +04:00
aykhans 8121d75a2c ci: verify nix/package.nix version matches the release tag 2026-08-18 13:03:07 +04:00
aykhans 3867140704 Merge pull request #217 from aykhans/chore/update-deps
chore: migrate to Charm v2 and update dependencies
2026-08-18 12:42:46 +04:00
aykhans 740b6f6bbb docs: update README 2026-08-17 21:16:40 +04:00
aykhans 88180ae288 ci: add nix build workflow and gate releases on it 2026-08-17 21:13:00 +04:00
aykhans 0314ea4f3e docs: update README 2026-08-17 20:56:46 +04:00
aykhans 9602595216 perf: batch TUI log forwarding on a 250ms tick instead of one repaint per line 2026-08-17 20:46:33 +04:00
aykhans c195ca2ab2 refactor: print stderr via fmt.Fprint with lipgloss.Sprintln instead of lipgloss.Fprintln 2026-08-17 20:11:47 +04:00
aykhans e13eb9b8a8 fix: treat zero-sized ptys as non-interactive 2026-08-17 19:11:55 +04:00
aykhans d68c557dd3 fix: restore pre-v2 progress bar fill and viewport scroll behavior 2026-08-17 18:57:33 +04:00
aykhans d08b6dc222 refactor: migrate Charm libraries to v2
Import paths moved to charm.land/*/v2 and the v1 paths no longer resolve.
Ports View() -> tea.View, AltScreen/MouseMode to View fields, KeyPressMsg,
and the new progress/viewport constructors. Styled output outside Bubble
Tea switched to lipgloss.Print* because v2 downsamples colour at the output
layer. vendorHash refreshed.
2026-08-17 17:21:13 +04:00
aykhans 8552aab3b1 chore(deps): update goja and indirect deps, refresh nix vendorHash 2026-08-17 13:12:39 +04:00
aykhans 79143beec9 Merge pull request #216 from aykhans/dependabot/go_modules/golang.org/x/net-0.58.0
build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
2026-08-17 12:02:46 +04:00
dependabot[bot] bb1ed1c2ce build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.57.0 to 0.58.0.
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 00:17:00 +00:00
aykhans a3434b096a Merge pull request #215 from aykhans/perf/remove-hot-path-contention
perf
2026-08-13 12:29:54 +04:00
aykhans e83e9346a3 perf: drop redundant resp.Reset in worker loops
fasthttp resets the response at the top of every HostClient.Do, so
resetting it in the worker loop only repeats that work once per request.
2026-08-10 00:10:27 +04:00
aykhans 01b5233167 perf: replace status code string map with an indexed table 2026-08-10 00:01:16 +04:00
aykhans e93b729145 Merge pull request #214 from aykhans/fix/nix-vendor-hash
nix: update vendorHash
2026-08-02 23:35:34 +04:00
aykhans 372d228bb1 nix: update vendorHash 2026-08-02 23:35:12 +04:00
aykhans 52a6e78e0c Merge pull request #213 from aykhans/perf/reduce-per-request-overhead
perf: reduce per-request overhead in the load generator
2026-08-02 22:39:48 +04:00
aykhans b22e3a0d25 nix: bump package version to 1.4.1 2026-08-02 22:37:56 +04:00
aykhans 4b52556b30 update README.md 2026-08-02 22:29:59 +04:00
aykhans 755545d838 perf: cut per-request allocations in the request generator
- Reuse value slices in place instead of clear()+realloc when a map's
  key set is static (skipped when scripts may mutate the maps).
- Use SetBodyString to drop the []byte(Body) copy, build the Cookie
  header with a strings.Builder, and skip QueryArgs when no params.
- Cache the values map when all values are literal, and share one empty
  map when none are configured.
- Reuse a single bytes.Buffer across template renders per worker.
2026-08-02 20:11:56 +04:00
aykhans dc2cd6cf63 Merge pull request #211 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.73.0
build(deps): bump github.com/valyala/fasthttp from 1.72.0 to 1.73.0
2026-07-28 13:34:06 +04:00
dependabot[bot] 838d65d83f build(deps): bump github.com/valyala/fasthttp from 1.72.0 to 1.73.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.72.0 to 1.73.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.72.0...v1.73.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 00:12:42 +00:00
aykhans 0fe0587823 Merge pull request #210 from aykhans/feat/nix-flake-packaging
Add nix flake packaging
2026-07-26 23:29:39 +04:00
aykhans 5b301c42a9 docs: unpin nix install commands in README 2026-07-26 23:26:19 +04:00
aykhans d35295c1d9 chore: bump go to 1.26.5 and update dependencies 2026-07-26 23:21:33 +04:00
aykhans a13d9d11f7 feat: add nix flake packaging 2026-07-26 22:55:24 +04:00
aykhans 3393f9e767 Merge pull request #209 from aykhans/docs/update-readme-badges
docs: update README badges
2026-07-24 00:22:47 +04:00
aykhans 2144f8e67d docs: update README badges 2026-07-24 00:21:48 +04:00
aykhans 9d1a73dd43 Merge pull request #207 from aykhans/feat/runtime-logging
feat(logging): add runtime log levels, response logging, and progress/log-file controls
2026-07-23 15:46:02 +04:00
aykhans f4d13d83aa fix(logging): match log levels case-sensitively 2026-07-23 00:15:21 +04:00
aykhans 07e9287c39 docs: document logging and progress config, drop quiet 2026-07-22 23:55:50 +04:00
aykhans 80a8ba1d59 feat(logging): add runtime log levels, response logging, and progress/log-file controls
- --log-level (comma-separated string: info, error): pick which logs are emitted;
  the level set is resolved once, up front
- Log responses at the info level: full self-contained JSON to a file or stderr,
  a compact "status duration | body" summary in the TUI log box
- Route logs independently of the progress display: the TUI log box when it runs,
  stderr when piped, or a file via --log-file/-w (parent dir validated up front)
- Replace --quiet with --progress (bar | none, default bar): progress and logs are
  now independent — progress=none still shows the log box; quiet == progress=none
  with an empty log level
- Rename the internal "message" concept to "log" (runtimeLog, runtimeLogger, ...)
2026-07-22 23:20:14 +04:00
aykhans 31af2d0d3c Merge pull request #206 from aykhans/dependabot/go_modules/golang.org/x/net-0.57.0
build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
2026-07-09 10:39:21 +04:00
dependabot[bot] 02b28dba2f build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 00:12:53 +00:00
aykhans d7bc0706ca Merge pull request #205 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.72.0
build(deps): bump github.com/valyala/fasthttp from 1.71.0 to 1.72.0
2026-07-01 15:32:01 +04:00
dependabot[bot] 6e5c6eb0fe build(deps): bump github.com/valyala/fasthttp from 1.71.0 to 1.72.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.71.0 to 1.72.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.71.0...v1.72.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 00:12:44 +00:00
aykhans a349ffd6dd Merge pull request #204 from aykhans/dependabot/go_modules/go.yaml.in/yaml/v4-4.0.0-rc.6
build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.5 to 4.0.0-rc.6
2026-06-22 12:08:01 +04:00
dependabot[bot] 606aec693c build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.5 to 4.0.0-rc.6
Bumps [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) from 4.0.0-rc.5 to 4.0.0-rc.6.
- [Commits](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.5...v4.0.0-rc.6)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v4
  dependency-version: 4.0.0-rc.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 00:15:53 +00:00
aykhans 99e442691d Merge pull request #203 from aykhans/dependabot/go_modules/golang.org/x/net-0.56.0
build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
2026-06-11 12:16:40 +04:00
dependabot[bot] 11ddabc7d4 build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.55.0 to 0.56.0.
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 00:12:53 +00:00
aykhans dd23ea73fd Merge pull request #202 from aykhans/dependabot/go_modules/go.yaml.in/yaml/v4-4.0.0-rc.5
build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.4 to 4.0.0-rc.5
2026-06-10 11:38:33 +04:00
dependabot[bot] c4faac9127 build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.4 to 4.0.0-rc.5
Bumps [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) from 4.0.0-rc.4 to 4.0.0-rc.5.
- [Commits](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.4...v4.0.0-rc.5)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v4
  dependency-version: 4.0.0-rc.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 00:12:54 +00:00
aykhans 7a75a4e096 Merge pull request #201 from aykhans/docs/fix-cycling-description
docs: fix multi-value cycling description for method, body, and proxy
2026-06-07 01:18:47 +04:00
aykhans ac6f1a1a65 docs: fix multi-value cycling description for method, body, and proxy 2026-06-07 01:18:24 +04:00
aykhans db19bee457 Merge pull request #200 from aykhans/chore/bump-deps
chore: bump Go to 1.26.4, update deps, add FreeBSD release binaries
2026-06-07 00:47:24 +04:00
aykhans 89c72e12e2 chore: bump Go to 1.26.4, update deps, add FreeBSD release binaries 2026-06-07 00:45:43 +04:00
aykhans 1c36e6fa70 Merge pull request #197 from aykhans/dependabot/go_modules/golang.org/x/net-0.55.0
build(deps): bump golang.org/x/net from 0.54.0 to 0.55.0
2026-05-25 10:50:47 +04:00
dependabot[bot] b0af20ddf5 build(deps): bump golang.org/x/net from 0.54.0 to 0.55.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 00:34:55 +00:00
44 changed files with 4218 additions and 1214 deletions

No files matched your search

+4 -4
View File
@@ -13,10 +13,10 @@ jobs:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: 1.26.3 go-version-file: go.mod
- name: go fix - name: go fix
run: | run: |
go fix ./... go fix ./...
@@ -24,4 +24,4 @@ jobs:
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.12.2 version: v2.14.0
+172
View File
@@ -0,0 +1,172 @@
name: nix
on:
push:
branches:
- main
pull_request:
paths:
- "go.mod"
- "go.sum"
- "nix/**"
- "flake.nix"
- "flake.lock"
workflow_call:
inputs:
ref:
description: "Ref to check out. Defaults to the triggering ref."
type: string
required: false
permissions:
contents: read
concurrency:
group: nix-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
# Keyed on the PR author, which is stable across the fix-up push, so the run
# started by that push cannot cancel the run that made it.
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' }}
env:
SELF_HOSTED_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
IS_DEPENDABOT: ${{ github.actor == 'dependabot[bot]' }}
jobs:
build:
name: build
runs-on: ubuntu-latest
permissions:
# The only write is git push, which authenticates with the PAT that
# checkout persists, not with GITHUB_TOKEN.
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
# head.sha, not the merge ref: the hash is computed for the tree that
# gets pushed, and the run that verifies it must build that same tree.
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
token: ${{ (env.IS_DEPENDABOT == 'true' && secrets.DEPENDABOT) || secrets.GITHUB_TOKEN }}
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix build
id: build
continue-on-error: ${{ env.SELF_HOSTED_PR == 'true' }}
run: nix build .#default --no-link --print-build-logs
- name: Recompute vendorHash
id: hash
if: steps.build.outcome == 'failure' && env.SELF_HOSTED_PR == 'true'
run: |
nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
# An unchanged hash means the build broke for some other reason. Say
# nothing and let the job fail on the real error.
if git diff --quiet -- nix/package.nix; then
echo "stale=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "stale=true" >> "$GITHUB_OUTPUT"
sed -n 's/^[[:space:]]*vendorHash = "\([^"]*\)".*/hash=\1/p' nix/package.nix >> "$GITHUB_OUTPUT"
- name: Push vendorHash fix
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT == 'true'
env:
HEAD_REF: ${{ github.head_ref }}
PUSH_TOKEN: ${{ secrets.DEPENDABOT }}
run: |
if [ -z "$PUSH_TOKEN" ]; then
echo "::error::the DEPENDABOT push token is missing from the Dependabot secret store"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nix/package.nix
git commit -m "fix(nix): update vendorHash [dependabot skip]"
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "::notice::vendorHash updated and pushed; the run on the new commit decides this PR"
- name: Comment stale vendorHash
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
HASH: ${{ steps.hash.outputs.hash }}
run: |
marker='<!-- vendorhash-bot -->'
body=$(cat <<EOF
$marker
### \`nix/package.nix\` vendorHash is stale
This branch changes the Go dependencies, so the vendor derivation no longer matches the pinned hash and \`nix build\` fails.
\`\`\`nix
vendorHash = "$HASH";
\`\`\`
Run \`task nix-hash\` locally and commit the result, or paste the hash above.
EOF
)
# Keep one sticky comment instead of a new one on every push. Without
# the explicit check a failed lookup reads as "no comment yet" and
# posts a duplicate; piping into head would hide the exit status.
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
echo "::error::could not list the pull request comments"
exit 1
fi
id=$(printf '%s\n' "$matches" | head -n 1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
else
gh pr comment "$PR" --body "$body"
fi
# Once the hash is fixed the build passes and the step above stops running,
# so without this the comment keeps claiming the branch is broken under a
# green check.
- name: Resolve stale vendorHash comment
# Gated on the hash rather than the build: a later push can fix the hash
# while the build still fails for an unrelated reason, and the comment
# must stop blaming the vendor derivation either way. Skipped when the
# recompute itself errored, since then we do not know.
if: ${{ !cancelled() && env.SELF_HOSTED_PR == 'true' && steps.hash.outputs.stale != 'true' && steps.hash.outcome != 'failure' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
BUILD: ${{ steps.build.outcome }}
run: |
marker='<!-- vendorhash-bot -->'
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
echo "::error::could not list the pull request comments"
exit 1
fi
id=$(printf '%s\n' "$matches" | head -n 1)
[ -n "$id" ] || exit 0
if [ "$BUILD" = "success" ]; then
detail='`nix build` passes on this branch.'
else
detail='The build is failing for another reason; see the workflow log.'
fi
body=$(cat <<EOF
$marker
### \`nix/package.nix\` vendorHash is up to date
$detail
EOF
)
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
- name: Report build failure
if: ${{ !cancelled() && steps.build.outcome == 'failure' && !(env.IS_DEPENDABOT == 'true' && steps.hash.outputs.stale == 'true') }}
run: |
echo "::error file=nix/package.nix::nix build failed; see the log above"
exit 1
+131 -33
View File
@@ -2,50 +2,101 @@ name: Build and Release
on: on:
release: release:
types: [created] types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.0.0)"
required: true
build_binaries:
description: "Build and upload binaries"
type: boolean
default: true
build_docker:
description: "Build and push Docker image"
type: boolean
default: true
permissions: permissions:
contents: write contents: write
jobs: jobs:
nix:
name: Verify Nix package
uses: ./.github/workflows/nix.yaml
permissions:
contents: read
pull-requests: write
with:
ref: ${{ github.ref }}
# Gate: the tag must come from main, agree with the pre-release flag, and match
# the hand-bumped version in nix/package.nix.
version:
name: Verify release
runs-on: ubuntu-latest
permissions:
contents: read
env:
TAG: ${{ github.ref_name }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
# Full history so the tag can be located on main's first-parent line.
fetch-depth: 0
- name: Check the tag is on main
run: |
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/first-parent.txt"
if ! grep -qxF "$GITHUB_SHA" "$RUNNER_TEMP/first-parent.txt"; then
echo "::error::$TAG ($GITHUB_SHA) is not on main; releases must be cut from main"
exit 1
fi
- name: Check the tag matches the pre-release flag
env:
PRERELEASE: ${{ github.event.release.prerelease }}
run: |
# The flag comes from the checkbox, not the tag name, so without this a
# tag like v1.5.0-rc1 published as stable would move :latest.
case "$TAG" in
*-*)
if [ "$PRERELEASE" != "true" ]; then
echo "::error::tag '$TAG' carries a pre-release suffix but the release is not marked as a pre-release"
exit 1
fi
;;
esac
- name: Check nix/package.nix matches the release tag
run: |
expected="${TAG#v}"
expected="${expected%%-*}"
actual="$(sed -n 's/^[[:space:]]*version = "\([^"]*\)".*/\1/p' nix/package.nix | head -1)"
if [ -z "$actual" ]; then
echo "::error file=nix/package.nix::could not read the version string"
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "::error file=nix/package.nix::version is '$actual' but the release tag is '$TAG' (expected '$expected')"
exit 1
fi
echo "nix/package.nix version '$actual' matches tag '$TAG'"
build: build:
name: Build binaries name: Build binaries
needs: [nix, version]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ github.ref }}
- name: Set build metadata - name: Set build metadata
run: | run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.26.3" >> $GITHUB_ENV echo "GO_VERSION=$(sed -n 's/^go //p' go.mod)" >> $GITHUB_ENV
- name: Set up Go - name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries uses: actions/setup-go@v7
uses: actions/setup-go@v6
with: with:
go-version: ${{ env.GO_VERSION }} go-version-file: go.mod
cache: true cache: true
- name: Build binaries - name: Build binaries
if: github.event_name == 'release' || inputs.build_binaries
run: | run: |
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \ LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \ -X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
@@ -55,36 +106,33 @@ jobs:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
- name: Upload Release Assets - name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ inputs.tag || github.ref_name }} tag_name: ${{ github.ref_name }}
files: ./sarin-* files: ./sarin-*
- name: Set up QEMU - name: Set up QEMU
if: github.event_name == 'release' || inputs.build_docker uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: github.event_name == 'release' || inputs.build_docker uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name == 'release' || inputs.build_docker uses: docker/login-action@v4
uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
if: github.event_name == 'release' || inputs.build_docker uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -93,6 +141,56 @@ jobs:
VERSION=${{ env.VERSION }} VERSION=${{ env.VERSION }}
GIT_COMMIT=${{ env.GIT_COMMIT }} GIT_COMMIT=${{ env.GIT_COMMIT }}
GO_VERSION=${{ env.GO_VERSION }} GO_VERSION=${{ env.GO_VERSION }}
# A pre-release must not take over the tag people get by default.
tags: | tags: |
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }} ${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/sarin:latest ${{ !github.event.release.prerelease && format('{0}/sarin:latest', secrets.DOCKERHUB_USERNAME) || '' }}
# Moving pointer at the newest stable release, so flake users can follow
# releases instead of main. Fast-forward only.
release-branch:
name: Advance release branch
needs: build
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest
permissions:
contents: write
# Serialize: concurrent releases would otherwise race the push.
concurrency:
group: release-branch
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# The checks below compare against $GITHUB_SHA; check out exactly that.
ref: ${{ github.sha }}
fetch-depth: 0
- name: Point release at the tag
env:
TAG: ${{ github.ref_name }}
run: |
# Refresh: checkout fetched this, but as of a few seconds ago.
git fetch --no-tags origin "+refs/heads/release:refs/remotes/origin/release" || true
if git rev-parse --verify --quiet refs/remotes/origin/release >/dev/null; then
# At or behind the pointer: a re-run of this or an older release.
if git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/release; then
if [ "$(git rev-parse refs/remotes/origin/release)" = "$GITHUB_SHA" ]; then
echo "::notice::release is already at $TAG"
else
echo "::warning::release is ahead of $TAG; leaving it alone"
fi
exit 0
fi
# The version job put this commit on main, so a pointer that is neither
# behind nor ahead is off main history and can never fast-forward again.
if ! git merge-base --is-ancestor refs/remotes/origin/release "$GITHUB_SHA"; then
echo "::error::release ($(git rev-parse --short refs/remotes/origin/release)) has diverged from main and can no longer fast-forward to $TAG; reset it by hand"
exit 1
fi
fi
git push origin "$GITHUB_SHA:refs/heads/release"
echo "::notice::release branch now points at $TAG"
+4
View File
@@ -1 +1,5 @@
bin/* bin/*
# Nix build artifacts
result
result-*
+6 -1
View File
@@ -1,7 +1,7 @@
version: "2" version: "2"
run: run:
go: "1.26" go: "1.27"
concurrency: 12 concurrency: 12
linters: linters:
@@ -81,6 +81,11 @@ linters:
- staticcheck - staticcheck
text: "SA5011" text: "SA5011"
- linters:
- errcheck
- gosec
source: "lipgloss\\.Println\\("
formatters: formatters:
enable: enable:
- gofmt - gofmt
+1 -1
View File
@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.3 ARG GO_VERSION=1.27.1
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
+51 -11
View File
@@ -3,7 +3,9 @@
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp. ## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
[![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin) [![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin)
[![Go Report Card](https://goreportcard.com/badge/go.aykhans.me/sarin)](https://goreportcard.com/report/go.aykhans.me/sarin) [![Docker Pulls](https://img.shields.io/docker/pulls/aykhans/sarin)](https://hub.docker.com/r/aykhans/sarin)
[![Downloads](https://img.shields.io/github/downloads/aykhans/sarin/total?cacheSeconds=3600)](https://github.com/aykhans/sarin/releases)
[![Lint](https://img.shields.io/github/actions/workflow/status/aykhans/sarin/lint.yaml?branch=main&label=lint)](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div> </div>
@@ -24,20 +26,19 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
| ✅ Supported | ❌ Not Supported | | ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | ------------------------------- | | ---------------------------------------------------------- | ------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis | | High-performance with low memory footprint | Web UI or complex TUI |
| Long-running duration/count based tests | Extensive response statistics | | Dynamic requests via 370+ template functions | Detailed response body analysis |
| Dynamic requests via 340+ template functions | Web UI or complex TUI |
| Request scripting with Lua and JavaScript | Distributed load testing | | Request scripting with Lua and JavaScript | Distributed load testing |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC | | Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem | | Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
| Flexible config (CLI, ENV, YAML) | |
## Installation ## Installation
### Docker (Recommended) <details open>
<summary><b>Docker</b></summary>
```sh ```sh
docker pull aykhans/sarin:latest docker run --rm -it aykhans/sarin:latest --version
``` ```
With a local config file: With a local config file:
@@ -52,13 +53,50 @@ With a remote config file:
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
``` ```
### Pre-built Binaries </details>
<details>
<summary><b>Nix</b></summary>
Run directly without installing (requires flakes enabled):
```sh
nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
```
Install into your profile:
```sh
nix profile add github:aykhans/sarin/release
```
Or add it to your own flake via the overlay:
```nix
{
inputs.sarin.url = "github:aykhans/sarin/release";
# In your outputs, apply the overlay to nixpkgs:
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
# then reference pkgs.sarin
}
```
The `release` branch tracks the newest stable release cut from `main`, so `nix flake update` moves between releases rather than following every commit; pre-releases are excluded. Pin an exact release with `github:aykhans/sarin/v1.4.2`, or track development with `github:aykhans/sarin` (the `main` branch).
</details>
<details>
<summary><b>Pre-built Binaries</b></summary>
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page. Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
### Building from Source </details>
Requires [Go 1.26+](https://golang.org/dl/). <details>
<summary><b>Building from Source</b></summary>
Requires the [Go version declared in `go.mod`](go.mod).
```sh ```sh
git clone https://github.com/aykhans/sarin.git && cd sarin git clone https://github.com/aykhans/sarin.git && cd sarin
@@ -72,6 +110,8 @@ CGO_ENABLED=0 go build \
-o sarin ./cmd/cli/main.go -o sarin ./cmd/cli/main.go
``` ```
</details>
## Quick Start ## Quick Start
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request: Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
@@ -106,7 +146,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
## Templating ## Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request. Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 370+ built-in functions to generate dynamic data for each request.
**Example:** **Example:**
+9 -3
View File
@@ -3,7 +3,9 @@ version: "3"
vars: vars:
BIN_DIR: ./bin BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.12.2 GO_VERSION:
sh: sed -n 's/^go //p' go.mod
GOLANGCI_LINT_VERSION: v2.14.0
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}" GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks: tasks:
@@ -39,6 +41,11 @@ tasks:
cmds: cmds:
- "{{.GOLANGCI}} run" - "{{.GOLANGCI}} run"
nix-hash:
desc: Recompute nix/package.nix vendorHash after a dependency change.
cmds:
- nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
test: test:
desc: Run Go tests. desc: Run Go tests.
cmds: cmds:
@@ -82,7 +89,6 @@ tasks:
vars: vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}' IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
TAG: '{{.TAG | default "latest"}}' TAG: '{{.TAG | default "latest"}}'
GO_VERSION: '{{.GO_VERSION | default ""}}'
VERSION: VERSION:
sh: git describe --tags --always sh: git describe --tags --always
GIT_COMMIT: GIT_COMMIT:
@@ -90,7 +96,7 @@ tasks:
cmds: cmds:
- >- - >-
docker build docker build
{{if .GO_VERSION}}--build-arg GO_VERSION={{.GO_VERSION}}{{end}} --build-arg GO_VERSION={{.GO_VERSION}}
--build-arg VERSION={{.VERSION}} --build-arg VERSION={{.VERSION}}
--build-arg GIT_COMMIT={{.GIT_COMMIT}} --build-arg GIT_COMMIT={{.GIT_COMMIT}}
-t {{.IMAGE_NAME}}:{{.TAG}} -t {{.IMAGE_NAME}}:{{.TAG}}
+12 -10
View File
@@ -7,6 +7,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"charm.land/lipgloss/v2"
"go.aykhans.me/sarin/internal/config" "go.aykhans.me/sarin/internal/config"
"go.aykhans.me/sarin/internal/sarin" "go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
@@ -32,13 +33,13 @@ func main() {
utilsErr.OnType(func(err types.FieldValidationErrors) error { utilsErr.OnType(func(err types.FieldValidationErrors) error {
for _, fieldErr := range err.Errors { for _, fieldErr := range err.Errors {
if fieldErr.Value == "" { if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(), config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
) ))
} else { } else {
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(), config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
) ))
} }
} }
os.Exit(1) os.Exit(1)
@@ -50,31 +51,32 @@ func main() {
ctx, ctx,
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout, combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration, *combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers, *combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody,
combinedConfig.Params, combinedConfig.Headers,
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values, combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone, *combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun, *combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
combinedConfig.Lua, combinedConfig.Js, combinedConfig.Lua, combinedConfig.Js,
) )
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error { utilsErr.OnType(func(err types.ProxyDialError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error()) fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error()))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error { utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error()) fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnType(func(err types.ScriptLoadError) error { utilsErr.OnType(func(err types.ScriptLoadError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error()) fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
) )
srn.Start(ctx, stopCtrl) srn.Start(stopCtrl)
switch *combinedConfig.Output { switch *combinedConfig.Output {
case config.ConfigOutputTypeNone: case config.ConfigOutputTypeNone:
+210 -64
View File
@@ -14,30 +14,33 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values. > **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
| Name | YAML | CLI | ENV | Default | Description | | Name | YAML | CLI | ENV | Default | Description |
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- | | --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | | [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | | [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration | | [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) | | [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) | | [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) | | [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout | | [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers | | [Max Response Body](#max-response-body) | `maxResponseBody`<br>(size) | `-max-response-body` / `-S`<br>(size) | `SARIN_MAX_RESPONSE_BODY`<br>(size) | `10MiB` | Largest response body to read |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send | | [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration | | [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs | | [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats | | [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending | | [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification | | [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body | | [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters | | [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers | | [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies | | [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) | | [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) | | [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) | | [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) | | [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
--- ---
@@ -131,7 +134,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
## Method ## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md). HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -141,9 +144,9 @@ method: GET
# OR # OR
method: method:
- GET - GET
- POST - POST
- PUT - PUT
``` ```
**CLI example:** **CLI example:**
@@ -160,15 +163,29 @@ SARIN_METHOD=GET
## Timeout ## Timeout
Request timeout. Must be greater than 0. Request timeout. Must be greater than 0. Defaults to `10s`.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `5s`, `300ms`, `1m20s` **Examples:** `5s`, `300ms`, `1m20s`
## Max Response Body
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory.
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `response body exceeds the body limit` and is counted like any other error.
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
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 ## Concurrency
Number of concurrent workers. Must be between 1 and 100,000,000. Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
## Requests ## Requests
@@ -182,15 +199,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `1m30s`, `25s`, `1h` **Examples:** `1m30s`, `25s`, `1h`
## Quiet ## Log Level
Hide the progress bar and runtime logs. Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
- `error`: errors that occur while generating a request. A request that fails to send is counted in the results instead
- `info`: every completed response
Leave empty to disable logging entirely.
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
## Log File
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
```sh
sarin -U http://example.com -r 1000 --log-file ./run.log
```
## Progress
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
## Output ## Output
Output format for response statistics. Output format for response statistics.
Valid formats: `table`, `json`, `yaml`, `none` Valid formats: `table` (default), `json`, `yaml`, `none`
Using `none` disables output and reduces memory usage since response statistics are not stored. Using `none` disables output and reduces memory usage since response statistics are not stored.
@@ -198,13 +234,17 @@ Using `none` disables output and reduces memory usage since response statistics
Generate requests without sending them. Useful for testing templates. Generate requests without sending them. Useful for testing templates.
A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back.
The duration in the results is how long generating each request took, templates and scripts included, so it is a different measurement from the response time a real run reports. A request with neither renders once before the run and reports `0s`.
## Insecure ## Insecure
Skip TLS certificate verification. Skip TLS certificate verification.
## Body ## Body
Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md). Request body. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -214,9 +254,9 @@ body: '{"product": "car"}'
# OR # OR
body: body:
- '{"product": "car"}' - '{"product": "car"}'
- '{"product": "phone"}' - '{"product": "phone"}'
- '{"product": "watch"}' - '{"product": "watch"}'
``` ```
**CLI example:** **CLI example:**
@@ -241,19 +281,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml ```yaml
params: params:
key1: value1 key1: value1
key2: [value2, value3] # cycles between value2 and value3 key2: [value2, value3] # cycles between value2 and value3
# OR # OR
params: params:
- key1: value1 - key1: value1
- key2: [value2, value3] # cycles between value2 and value3 - key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries: # To send both values in every request, use separate entries:
params: params:
- key2: value2 - key2: value2
- key2: value3 # both sent in every request - key2: value3 # both sent in every request
``` ```
**CLI example:** **CLI example:**
@@ -278,19 +318,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml ```yaml
headers: headers:
key1: value1 key1: value1
key2: [value2, value3] # cycles between value2 and value3 key2: [value2, value3] # cycles between value2 and value3
# OR # OR
headers: headers:
- key1: value1 - key1: value1
- key2: [value2, value3] # cycles between value2 and value3 - key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries: # To send both values in every request, use separate entries:
headers: headers:
- key2: value2 - key2: value2
- key2: value3 # both sent in every request - key2: value3 # both sent in every request
``` ```
**CLI example:** **CLI example:**
@@ -315,19 +355,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml ```yaml
cookies: cookies:
key1: value1 key1: value1
key2: [value2, value3] # cycles between value2 and value3 key2: [value2, value3] # cycles between value2 and value3
# OR # OR
cookies: cookies:
- key1: value1 - key1: value1
- key2: [value2, value3] # cycles between value2 and value3 - key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries: # To send both values in every request, use separate entries:
cookies: cookies:
- key2: value2 - key2: value2
- key2: value3 # both sent in every request - key2: value3 # both sent in every request
``` ```
**CLI example:** **CLI example:**
@@ -344,7 +384,7 @@ SARIN_COOKIE="key1=value1"
## Proxy ## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Proxy URL(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point.
Supported protocols: `http`, `https`, `socks5`, `socks5h` Supported protocols: `http`, `https`, `socks5`, `socks5h`
@@ -356,9 +396,9 @@ proxy: http://proxy1.com
# OR # OR
proxy: proxy:
- http://proxy1.com - http://proxy1.com
- socks5://proxy2.com - socks5://proxy2.com
- socks5h://proxy3.com - socks5h://proxy3.com
``` ```
**CLI example:** **CLI example:**
@@ -387,9 +427,9 @@ values: "key=value"
# OR # OR
values: | values: |
key1=value1 key1=value1
key2=value2 key2=value2
key3=value3 key3=value3
``` ```
**CLI example:** **CLI example:**
@@ -435,7 +475,9 @@ function transform(req)
end end
``` ```
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). > **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
**YAML example:** **YAML example:**
@@ -500,7 +542,9 @@ function transform(req) {
} }
``` ```
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). > **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped.
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
**YAML example:** **YAML example:**
@@ -533,3 +577,105 @@ js:
```sh ```sh
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }' SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
``` ```
## HTTP Requests in Scripts
Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint.
**Function:**
```
http(url, opts)
```
`url` must be an absolute `http` or `https` URL. `opts` is optional.
**Options:**
| Option | Type | Default | Description |
| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `method` | string | `GET` | HTTP method, sent as given |
| `headers` | table/object | - | Request headers, same shape as `req.headers` |
| `params` | table/object | - | Query parameters, added to any already in the URL |
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
| `body` | string | `""` | Request body |
| `timeout` | duration string (`"500ms"`) | `10s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
Unknown options and values of the wrong type raise an error.
**Response:**
| Field / Method | Description |
| -------------- | ------------------------------------------------------------------ |
| `status` | Status code (number) |
| `headers` | Response headers, each key holding an array of values |
| `body` | Response body (string) |
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
| `json()` | JavaScript only: parses the body as JSON |
| `bytes()` | JavaScript only: the body as a `Uint8Array` |
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
> **Note:** JavaScript strings hold UTF-16, so `body` replaces bytes that are not valid UTF-8 and `body.length` counts code units rather than bytes. Use `bytes()` for binary responses and for the real byte count. Lua strings keep the bytes as they arrived.
**Behavior:**
- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`).
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
- **Status codes:** non-2xx responses are returned normally; check `status` in the script.
- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript).
- **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch.
- **Compressed responses:** sarin asks for no encoding, so this only applies when a script sends `Accept-Encoding` itself or the server compresses unasked. A `gzip`, `deflate`, `br` or `zstd` body is then decoded before the script sees it, and `Content-Encoding` and `Content-Length` are both dropped from `headers` because neither describes what the script receives. Anything else is left raw with its header intact: an empty body, `identity`, a `Content-Encoding` sarin does not recognise, and two or more codings at once. A body that fails to decode raises an error the script can catch, though `br` carries no checksum, so damage to a brotli body can go unnoticed.
- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts.
- **Dry run:** script requests are still sent in dry-run mode.
- **Not measured:** script requests are not included in the results.
**Lua JSON:**
Lua has no built-in JSON support, so Lua scripts get a global `json` table:
- `json.decode(string)`: arrays become 1-indexed tables and `null` becomes `nil` (so `null` array items leave holes).
- `json.encode(value)`: a table with keys exactly `1..n` becomes an array, any other table becomes an object, and an empty table becomes `{}`.
JavaScript scripts use the built-in `JSON` object.
**Lua example:**
```lua
function transform(req)
local res = http("https://api.example.com/login", {
method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ user = "test", password = "secret" }),
timeout = "2s",
})
if res.status ~= 200 then
error("login failed: " .. res.status)
end
req.headers["Authorization"] = "Bearer " .. json.decode(res.body).token
return req
end
```
**JavaScript example:**
```javascript
function transform(req) {
const res = http("https://api.example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "test", password: "secret" }),
timeout: "2s",
});
if (res.status !== 200) {
throw new Error("login failed: " + res.status);
}
req.headers["Authorization"] = "Bearer " + res.json().token;
return req;
}
```
+176 -5
View File
@@ -13,6 +13,7 @@ This guide provides practical examples for common Sarin use cases.
- [File Uploads](#file-uploads) - [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies) - [Using Proxies](#using-proxies)
- [Output Formats](#output-formats) - [Output Formats](#output-formats)
- [Runtime Logging](#runtime-logging)
- [Docker Usage](#docker-usage) - [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode) - [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration) - [Show Configuration](#show-configuration)
@@ -372,7 +373,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
</details> </details>
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**. > For the complete list of 370+ template functions, see the **[Templating Guide](templating.md)**.
## Solving Captchas ## Solving Captchas
@@ -719,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
</details> </details>
**Multiple proxies (load balanced):** **Multiple proxies (randomly cycled):**
```sh ```sh
sarin -U http://example.com -r 1000 -c 10 \ sarin -U http://example.com -r 1000 -c 10 \
@@ -836,10 +837,10 @@ output: none
</details> </details>
**Quiet mode (hide progress bar):** **Hide the progress bar:**
```sh ```sh
sarin -U http://example.com -r 1000 -c 10 -q sarin -U http://example.com -r 1000 -c 10 -p none
``` ```
<details> <details>
@@ -849,7 +850,41 @@ sarin -U http://example.com -r 1000 -c 10 -q
url: http://example.com url: http://example.com
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
quiet: true progress: none
```
</details>
## Runtime Logging
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers errors raised while generating a request, `info` covers every completed response (status, duration, headers, body). A request that fails to send is counted in the results rather than logged. Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
**Log responses and errors:**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info,error
```
**Write logs to a file (the progress bar stays on screen):**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
```
**Capture logs while keeping results on stdout:**
```sh
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
logLevel: info
output: json
``` ```
</details> </details>
@@ -1051,3 +1086,139 @@ js: "@./scripts/body.js"
``` ```
</details> </details>
**Fetch a value from another endpoint before each request:**
Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options.
```sh
sarin -U http://example.com/api/orders -r 1000 -c 10 \
-M POST \
-lua 'function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/orders
requests: 1000
concurrency: 10
method: POST
lua: |
function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end
```
</details>
**Log in once per worker and reuse the token (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 1000
concurrency: 10
js: |
let token;
function login() {
const res = http("http://example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "test", password: "secret" }),
});
if (res.status !== 200) throw new Error("login failed: " + res.status);
return res.json().token;
}
function transform(req) {
if (!token) token = login();
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Chain two calls, then use the result in the URL path (Lua):**
```yaml
url: http://example.com/api/placeholder
requests: 500
concurrency: 10
lua: |
function transform(req)
local token = json.decode(http("http://example.com/login", {
method = "POST",
body = json.encode({ user = "test", password = "secret" }),
headers = { ["Content-Type"] = "application/json" },
}).body).token
local orders = json.decode(http("http://example.com/api/orders", {
headers = { Authorization = "Bearer " .. token },
}).body)
req.path = "/api/orders/" .. orders[1].id
req.headers["Authorization"] = "Bearer " .. token
return req
end
```
**Refresh a token only when it expires (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 5000
concurrency: 20
js: |
let token;
let expiresAt = 0;
function transform(req) {
if (Date.now() >= expiresAt) {
const res = http("http://example.com/login", { method: "POST", body: "{}" });
token = res.json().token;
expiresAt = Date.now() + 60000; // refresh once a minute per worker
}
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Keep sending the main request even when the extra call fails:**
A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself:
```sh
sarin -U http://example.com/api/items -r 1000 -c 10 \
-lua 'function transform(req)
local ok, res = pcall(http, "http://example.com/api/flags")
if ok and res.status == 200 then
req.headers["X-Flags"] = res.body
else
req.headers["X-Flags"] = "default"
end
return req
end'
```
The JavaScript equivalent:
```sh
sarin -U http://example.com/api/items -r 1000 -c 10 \
-js 'function transform(req) {
try {
req.headers["X-Flags"] = http("http://example.com/api/flags").body;
} catch (e) {
req.headers["X-Flags"] = "default";
}
return req;
}'
```
+194 -155
View File
@@ -2,9 +2,9 @@
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. 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. > **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file.
## Table of Contents ## Table of Contents
@@ -29,6 +29,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
- [Generate](#generate) - [Generate](#generate)
- [Auth](#auth) - [Auth](#auth)
- [Address](#address) - [Address](#address)
- [Airline](#airline)
- [Game](#game) - [Game](#game)
- [Beer](#beer) - [Beer](#beer)
- [Car](#car) - [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_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` |
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` | | `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"` | | `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 ### Crypto Functions
@@ -165,6 +166,8 @@ body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
### Body Functions ### Body Functions
> **Note:** These are available in `body` only, not in the URL, headers, params, cookies or values.
| Function | Description | Example | | 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" }}` | | `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" }}` |
@@ -183,19 +186,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
# Mixed text fields and files # Mixed text fields and files
body: | body: |
{{ body_FormData {{ body_FormData
"title" "My Report" "title" "My Report"
"author" "John Doe" "author" "John Doe"
"cover" "@/path/to/cover.jpg" "cover" "@/path/to/cover.jpg"
"document" "@/path/to/report.pdf" "document" "@/path/to/report.pdf"
}} }}
# Multiple files with same field name # Multiple files with same field name
body: | body: |
{{ body_FormData {{ body_FormData
"files" "@/path/to/file1.pdf" "files" "@/path/to/file1.pdf"
"files" "@/path/to/file2.pdf" "files" "@/path/to/file2.pdf"
}} }}
# Escape @ for literal value (sends "@username") # Escape @ for literal value (sends "@username")
body: '{{ body_FormData "twitter" "@@username" }}' body: '{{ body_FormData "twitter" "@@username" }}'
@@ -226,7 +229,7 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}' body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
# Combined with values for reuse # Combined with values for reuse
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}" values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}'
body: '{"data": "{{ .Values.FILE_DATA }}"}' body: '{"data": "{{ .Values.FILE_DATA }}"}'
``` ```
@@ -234,7 +237,7 @@ body: '{"data": "{{ .Values.FILE_DATA }}"}'
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile. Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template. The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required. Bring your own key and use any of the supported services per template.
> **Important: performance and cost:** > **Important: performance and cost:**
> >
@@ -249,7 +252,7 @@ The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `ca
### 2Captcha ### 2Captcha
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API. Functions for the [2Captcha](https://2captcha.com) service. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
| Function | Description | | Function | Description |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
@@ -259,7 +262,7 @@ Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **doe
### Anti-Captcha ### Anti-Captcha
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end. Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is the only Sarin captcha integration that exposes an hCaptcha function.
| Function | Description | | Function | Description |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
@@ -270,7 +273,7 @@ Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is curr
### CapSolver ### CapSolver
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha. Functions for the [CapSolver](https://capsolver.com) service. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
| Function | Description | | Function | Description |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -302,7 +305,7 @@ body: |
``` ```
```yaml ```yaml
# hCaptcha via Anti-Captcha (the only service that still supports it) # hCaptcha via Anti-Captcha
method: POST method: POST
url: https://example.com/protected url: https://example.com/protected
body: | body: |
@@ -325,10 +328,10 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### File ### File
| Function | Description | Example Output | | Function | Description | Example Output |
| ---------------------- | -------------- | -------------------- | | ---------------------- | -------------- | --------------------- |
| `fakeit_FileExtension` | File extension | `"nes"` | | `fakeit_FileExtension` | File extension | `"nes"` |
| `fakeit_FileMimeType` | MIME type | `"application/json"` | | `fakeit_FileMimeType` | MIME type | `"application/x-wri"` |
### ID ### ID
@@ -347,7 +350,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` | | `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
| `fakeit_ProductMaterial` | Product material | `"brass"` | | `fakeit_ProductMaterial` | Product material | `"brass"` |
| `fakeit_ProductUPC` | UPC code | `"012780949980"` | | `fakeit_ProductUPC` | UPC code | `"012780949980"` |
| `fakeit_ProductAudience` | Target audience | `["adults"]` | | `fakeit_ProductAudience` | Target audience | `[adults]` |
| `fakeit_ProductDimension` | Product dimension | `"medium"` | | `fakeit_ProductDimension` | Product dimension | `"medium"` |
| `fakeit_ProductUseCase` | Use case | `"home"` | | `fakeit_ProductUseCase` | Use case | `"home"` |
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` | | `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
@@ -356,23 +359,25 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Person ### Person
| Function | Description | Example Output | | Function | Description | Example Output |
| ----------------------- | ---------------------- | ------------------------ | | ----------------------- | ---------------------- | ------------------------------------------------------------------- |
| `fakeit_Name` | Full name | `"Markus Moen"` | | `fakeit_Name` | Full name | `"Priscilla Thornton"` |
| `fakeit_NamePrefix` | Name prefix | `"Mr."` | | `fakeit_NamePrefix` | Name prefix | `"Mr."` |
| `fakeit_NameSuffix` | Name suffix | `"Jr."` | | `fakeit_NameSuffix` | Name suffix | `"Jr."` |
| `fakeit_FirstName` | First name | `"Markus"` | | `fakeit_FirstName` | First name | `"Priscilla"` |
| `fakeit_MiddleName` | Middle name | `"Belinda"` | | `fakeit_MiddleName` | Middle name | `"Belinda"` |
| `fakeit_LastName` | Last name | `"Daniel"` | | `fakeit_LastName` | Last name | `"Daniel"` |
| `fakeit_Gender` | Gender | `"male"` | | `fakeit_Gender` | Gender | `"male"` |
| `fakeit_Age` | Age | `40` | | `fakeit_Age` | Age | `40` |
| `fakeit_Ethnicity` | Ethnicity | `"German"` | | `fakeit_Ethnicity` | Ethnicity | `"German"` |
| `fakeit_SSN` | Social Security Number | `"296446360"` | | `fakeit_SSN` | Social Security Number | `"296446360"` |
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` | | `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
| `fakeit_Hobby` | Hobby | `"Swimming"` | | `fakeit_Hobby` | Hobby | `"Swimming"` |
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` | | `fakeit_SocialMedia` | Social media profile | `"https://twitter.com/ImpossibleTrousers"` |
| `fakeit_Phone` | Phone number | `"6136459948"` | | `fakeit_Bio` | Biography | `"Born in New York, John grew up to become a Software Engineer..."` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` | | `fakeit_Email` | Email address | `"priscilla.thornton@duncan.biz"` |
| `fakeit_Phone` | Phone number | `"6136459948"` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
### Generate ### Generate
@@ -385,13 +390,13 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example | | Function | Description | Example |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Username` | Username | `"Daniel1364"` | | `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 ### Address
| Function | Description | Example Output | | Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- | | --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` | | `fakeit_City` | City name | `"Reno"` |
| `fakeit_Country` | Country name | `"United States of America"` | | `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` | | `fakeit_CountryAbr` | Country abbreviation | `"US"` |
| `fakeit_State` | State name | `"Illinois"` | | `fakeit_State` | State name | `"Illinois"` |
@@ -400,7 +405,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_StreetName` | Street name | `"View"` | | `fakeit_StreetName` | Street name | `"View"` |
| `fakeit_StreetNumber` | Street number | `"13645"` | | `fakeit_StreetNumber` | Street number | `"13645"` |
| `fakeit_StreetPrefix` | Street prefix | `"East"` | | `fakeit_StreetPrefix` | Street prefix | `"East"` |
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` | | `fakeit_StreetSuffix` | Street suffix | `"side"` |
| `fakeit_Unit` | Unit | `"Apt 123"` | | `fakeit_Unit` | Unit | `"Apt 123"` |
| `fakeit_Zip` | ZIP code | `"13645"` | | `fakeit_Zip` | ZIP code | `"13645"` |
| `fakeit_Latitude` | Random latitude | `-73.534056` | | `fakeit_Latitude` | Random latitude | `-73.534056` |
@@ -408,11 +413,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` | | `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` | | `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` |
### Airline
| Function | Description | Example Output |
| ----------------------------- | ----------------- | ---------------------------------------------------- |
| `fakeit_AirlineAircraftType` | Aircraft type | `"narrowbody"` |
| `fakeit_AirlineAirplane` | Airplane model | `"Airbus A320"` |
| `fakeit_AirlineAirport` | Airport name | `"Hartsfield-Jackson Atlanta International Airport"` |
| `fakeit_AirlineAirportIATA` | Airport IATA code | `"ATL"` |
| `fakeit_AirlineFlightNumber` | Flight number | `"AA1234"` |
| `fakeit_AirlineRecordLocator` | Booking reference | `"ABCDEF"` |
| `fakeit_AirlineSeat` | Seat assignment | `"12A"` |
### Game ### Game
| Function | Description | Example Output | | Function | Description | Example Output |
| ----------------- | ----------- | ------------------- | | ----------------- | ----------- | ----------------- |
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` | | `fakeit_Gamertag` | Gamer tag | `"TurkeyThinker"` |
### Beer ### Beer
@@ -443,18 +460,22 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| ---------------------------------- | --------------------------- | ---------------- | | ---------------------------------- | --------------------------- | ---------------- |
| `fakeit_Word` | Random word | `"example"` | | `fakeit_Word` | Random word | `"example"` |
| `fakeit_Noun` | Random noun | `"computer"` | | `fakeit_Noun` | Random noun | `"computer"` |
| `fakeit_NounCommon` | Common noun | `"table"` | | `fakeit_NounCommon` | Common noun | `"group"` |
| `fakeit_NounConcrete` | Concrete noun | `"chair"` | | `fakeit_NounConcrete` | Concrete noun | `"chair"` |
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` | | `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` | | `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"herd"` | | `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"leap"` |
| `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` | | `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
| `fakeit_NounCountable` | Countable noun | `"book"` | | `fakeit_NounCountable` | Countable noun | `"book"` |
| `fakeit_NounUncountable` | Uncountable noun | `"water"` | | `fakeit_NounUncountable` | Uncountable noun | `"water"` |
| `fakeit_Verb` | Random verb | `"run"` | | `fakeit_NounProper` | Proper noun | `"John"` |
| `fakeit_NounDeterminer` | Noun determiner | `"your"` |
| `fakeit_Verb` | Random verb | `"would"` |
| `fakeit_VerbAction` | Action verb | `"jump"` | | `fakeit_VerbAction` | Action verb | `"jump"` |
| `fakeit_VerbLinking` | Linking verb | `"is"` | | `fakeit_VerbLinking` | Linking verb | `"is"` |
| `fakeit_VerbHelping` | Helping verb | `"can"` | | `fakeit_VerbHelping` | Helping verb | `"can"` |
| `fakeit_VerbTransitive` | Transitive verb | `"follow"` |
| `fakeit_VerbIntransitive` | Intransitive verb | `"laugh"` |
| `fakeit_Adverb` | Random adverb | `"quickly"` | | `fakeit_Adverb` | Random adverb | `"quickly"` |
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` | | `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
| `fakeit_AdverbDegree` | Degree adverb | `"very"` | | `fakeit_AdverbDegree` | Degree adverb | `"very"` |
@@ -468,7 +489,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` | | `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` | | `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
| `fakeit_Adjective` | Random adjective | `"beautiful"` | | `fakeit_Adjective` | Random adjective | `"beautiful"` |
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` | | `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"tired"` |
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` | | `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` | | `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` | | `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
@@ -483,26 +504,33 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` | | `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` | | `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
| `fakeit_PronounRelative` | Relative pronoun | `"which"` | | `fakeit_PronounRelative` | Relative pronoun | `"which"` |
| `fakeit_PronounIndefinite` | Indefinite pronoun | `"few"` |
| `fakeit_Connective` | Random connective | `"however"` | | `fakeit_Connective` | Random connective | `"however"` |
| `fakeit_ConnectiveTime` | Time connective | `"then"` | | `fakeit_ConnectiveTime` | Time connective | `"then"` |
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` | | `fakeit_ConnectiveComparative` | Comparative connective | `"after all"` |
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` | | `fakeit_ConnectiveComplaint` | Complaint connective | `"i.e."` |
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` | | `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` | | `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` | | `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
| `fakeit_Interjection` | Interjection | `"wow"` |
### Text ### Text
| Function | Description | Example | | Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- | | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` | | `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` | | `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` | | `fakeit_LoremIpsumWord` | Lorem ipsum word | `"eveniet"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | | `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | | `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is your name?"` | | `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
| `fakeit_Quote` | Random quote | `"Life is what happens..."` | | `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` | | `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
| `fakeit_PhraseAdverb` | Adverb phrase | `"fully gladly"` |
| `fakeit_PhrasePreposition` | Preposition phrase | `"out the black thing"` |
| `fakeit_Comment` | Comment | `"add some a little bit team"` |
### Foods ### Foods
@@ -515,6 +543,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` | | `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
| `fakeit_Snack` | Snack | `"Trail mix"` | | `fakeit_Snack` | Snack | `"Trail mix"` |
| `fakeit_Dessert` | Dessert | `"French napoleons"` | | `fakeit_Dessert` | Dessert | `"French napoleons"` |
| `fakeit_Drink` | Drink | `"Soda"` |
### Misc ### Misc
@@ -525,20 +554,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Color ### Color
| Function | Description | Example Output | | Function | Description | Example Output |
| ------------------- | ------------------ | --------------------------------------------------------- | | ------------------- | ------------------ | ------------------------------------------- |
| `fakeit_Color` | Color name | `"MediumOrchid"` | | `fakeit_Color` | Color name | `"MediumOrchid"` |
| `fakeit_HexColor` | Hex color | `"#a99fb4"` | | `fakeit_HexColor` | Hex color | `"#a99fb4"` |
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` | | `fakeit_RGBColor` | RGB color | `[85 224 195]` |
| `fakeit_SafeColor` | Safe color | `"black"` | | `fakeit_HSLColor` | HSL color | `[210 85 50]` |
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` | | `fakeit_SafeColor` | Safe color | `"black"` |
| `fakeit_NiceColors` | Nice color palette | `[#cfffdd #b4dec1 #5c5863 #a85163 #ff1f4c]` |
### Image ### Image
| Function | Description | Example | | Function | Description | Example |
| ----------------------------------------- | ------------------------- | -------------------------------- | | ----------------------------------------- | -------------------------- | -------------------------------- |
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` | | `fakeit_ImageJpeg(width int, height int)` | Generate a JPEG byte slice | `{{ fakeit_ImageJpeg 100 100 }}` |
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 100 100 }}` | | `fakeit_ImagePng(width int, height int)` | Generate a PNG byte slice | `{{ fakeit_ImagePng 100 100 }}` |
> **Note:** Go templates render byte slices as decimal lists, not raw binary image data. Use `{{ json_Encode (fakeit_ImagePng 100 100) }}` to produce a Base64-encoded JSON string. These functions cannot write raw image bytes directly through body templating.
### Internet ### Internet
@@ -567,16 +599,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example Output | | Function | Description | Example Output |
| ------------------ | --------------- | ------------------ | | ------------------ | --------------- | ------------------ |
| `fakeit_InputName` | HTML input name | `"email"` | | `fakeit_InputName` | HTML input name | `"message"` |
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` | | `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
### Date/Time ### Date/Time
| Function | Description | Example | | Function | Description | Example |
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | | -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` | | `fakeit_Date` | Random date | `2013-11-07 04:31:13.726582492 +0000 UTC` |
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` | | `fakeit_PastDate` | Date 1–12 hours before now | Current time − 1–12 hours |
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` | | `fakeit_FutureDate` | Date 1–12 hours after now | Current time + 1–12 hours |
| `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` | | `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` |
| `fakeit_NanoSecond` | Nanosecond | `123456789` | | `fakeit_NanoSecond` | Nanosecond | `123456789` |
| `fakeit_Second` | Second (0-59) | `45` | | `fakeit_Second` | Second (0-59) | `45` |
@@ -587,11 +619,11 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Day` | Day (1-31) | `15` | | `fakeit_Day` | Day (1-31) | `15` |
| `fakeit_WeekDay` | Weekday | `"Monday"` | | `fakeit_WeekDay` | Weekday | `"Monday"` |
| `fakeit_Year` | Year | `2024` | | `fakeit_Year` | Year | `2024` |
| `fakeit_TimeZone` | Timezone | `"America/New_York"` | | `fakeit_TimeZone` | Timezone name | `"West Pacific Standard Time"` |
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` | | `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
| `fakeit_TimeZoneFull` | Full timezone | `"Eastern Standard Time"` | | `fakeit_TimeZoneFull` | Full timezone | `"(UTC+10:00) Guam, Port Moresby"` |
| `fakeit_TimeZoneOffset` | Timezone offset | `-5` | | `fakeit_TimeZoneOffset` | Timezone offset | `-5` |
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` | | `fakeit_TimeZoneRegion` | Timezone region | `"Indian/Chagos"` |
### Payment ### Payment
@@ -607,8 +639,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_AchRouting` | ACH routing number | `"513715684"` | | `fakeit_AchRouting` | ACH routing number | `"513715684"` |
| `fakeit_AchAccount` | ACH account number | `"491527954328"` | | `fakeit_AchAccount` | ACH account number | `"491527954328"` |
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` | | `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` | | `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5JMZxkQX2PgaasaHc8wnWLNdMu7rxeU7xS64ev7RWNinacicPfm"` |
| `fakeit_BankName` | Bank name | `"Wells Fargo"` | | `fakeit_BankName` | Bank name | `"Toronto Dominion Bank"` |
| `fakeit_BankType` | Bank type | `"Investment Bank"` | | `fakeit_BankType` | Bank type | `"Investment Bank"` |
### Finance ### Finance
@@ -620,17 +652,17 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Company ### Company
| Function | Description | Example Output | | Function | Description | Example Output |
| ---------------------- | -------------- | ------------------------------------------ | | ---------------------- | -------------- | ---------------------- |
| `fakeit_BS` | Business speak | `"front-end"` | | `fakeit_BS` | Business speak | `"front-end"` |
| `fakeit_Blurb` | Company blurb | `"word"` | | `fakeit_Blurb` | Company blurb | `"Teamwork"` |
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` | | `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` | | `fakeit_Company` | Company name | `"TransparaGov"` |
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` | | `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` | | `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
| `fakeit_JobLevel` | Job level | `"Assurance"` | | `fakeit_JobLevel` | Job level | `"Assurance"` |
| `fakeit_JobTitle` | Job title | `"Director"` | | `fakeit_JobTitle` | Job title | `"Director"` |
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` | | `fakeit_Slogan` | Company slogan | `"Made of styrofoam."` |
### Hacker ### Hacker
@@ -695,9 +727,9 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` | | `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` | | `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
| `fakeit_EmojiJob` | Job emoji | `"👨‍💻"` | | `fakeit_EmojiJob` | Job emoji | `"👨‍💻"` |
| `fakeit_EmojiPerson` | Person emoji | `"👤"` | | `fakeit_EmojiPerson` | Person emoji | `"🧓"` |
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` | | `fakeit_EmojiGesture` | Gesture emoji | `"🤦‍♀️"` |
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` | | `fakeit_EmojiCostume` | Costume emoji | `"🧞"` |
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` | | `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
### Language ### Language
@@ -706,31 +738,35 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| ----------------------------- | --------------------- | -------------- | | ----------------------------- | --------------------- | -------------- |
| `fakeit_Language` | Language | `"English"` | | `fakeit_Language` | Language | `"English"` |
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` | | `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
| `fakeit_LanguageBCP` | BCP language tag | `"en-US"` |
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` | | `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
### Number ### Number
| Function | Description | Example | | Function | Description | Example |
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ | | ----------------------------------------------- | ----------------------------------- | -------------------------------------------- |
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` | | `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` | | `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` | | `fakeit_IntN(n int)` | Random int in `[0, n)` | `{{ fakeit_IntN 100 }}` |
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` | | `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` | | `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` | | `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` | | `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` | | `fakeit_IntRange(min int, max int)` | Random int in range | `{{ fakeit_IntRange -10 10 }}` |
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` | | `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` | | `fakeit_UintN(n uint)` | Random uint in `[0, n)` | `{{ fakeit_UintN 100 }}` |
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` | | `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` | | `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` | | `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` | | `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` | | `fakeit_UintRange(min uint, max uint)` | Random uint in range | `{{ fakeit_UintRange 10 100 }}` |
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` | | `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` | | `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` | | `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` | | `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
| `fakeit_RandomUint(slice []uint)` | Random uint from slice | `{{ fakeit_RandomUint (slice_Uint 1 2 3) }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
### String ### String
@@ -740,6 +776,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}` → `"71364"` | | `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}` → `"71364"` |
| `fakeit_Letter` | Single random letter | `"g"` | | `fakeit_Letter` | Single random letter | `"g"` |
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` | | `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` |
| `fakeit_Vowel` | Single lowercase vowel | `"a"` |
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` | | `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` |
| `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}` → `"(555)867-5309"` | | `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}` → `"(555)867-5309"` |
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` | | `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
@@ -754,26 +791,26 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Minecraft ### Minecraft
| Function | Description | Example Output | | Function | Description | Example Output |
| --------------------------------- | ----------------- | ---------------- | | --------------------------------- | ----------------- | ------------------- |
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` | | `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` | | `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` | | `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` | | `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` | | `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` | | `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` | | `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` | | `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` | | `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` | | `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` | | `fakeit_MinecraftVillagerStation` | Villager station | `"stonecutter"` |
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` | | `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` | | `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` | | `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` | | `fakeit_MinecraftMobHostile` | Hostile mob | `"wither skeleton"` |
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` | | `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` | | `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` | | `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
### Book ### Book
@@ -792,26 +829,28 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Error ### Error
| Function | Description | Example Output | | Function | Description | Example Output |
| ------------------------ | ----------------- | ---------------------------------- | | ------------------------ | ----------------- | ----------------------------------------------- |
| `fakeit_Error` | Random error | `"connection refused"` | | `fakeit_Error` | Random error | `"variable assigned before declaration"` |
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` | | `fakeit_ErrorObject` | Error object | `"protocol"` |
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` | | `fakeit_ErrorDatabase` | Database error | `"destination pointer is nil"` |
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` | | `fakeit_ErrorGRPC` | gRPC error | `"connection refused"` |
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` | | `fakeit_ErrorHTTP` | HTTP error | `"wrote more than the declared Content-Length"` |
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` | | `fakeit_ErrorHTTPClient` | HTTP client error | `"expectation failed"` |
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` | | `fakeit_ErrorHTTPServer` | HTTP server error | `"not extended"` |
| `fakeit_ErrorRuntime` | Runtime error | `"expected 2 arguments, got 3"` |
| `fakeit_ErrorValidation` | Validation error | `"missing required field"` |
### School ### School
| Function | Description | Example Output | | Function | Description | Example Output |
| --------------- | ----------- | ---------------------- | | --------------- | ----------- | ---------------------------- |
| `fakeit_School` | School name | `"Harvard University"` | | `fakeit_School` | School name | `"Hillside Private Academy"` |
### Song ### Song
| Function | Description | Example Output | | Function | Description | Example Output |
| ------------------- | ----------- | --------------------- | | ------------------- | ----------- | ------------------------ |
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` | | `fakeit_SongName` | Song name | `"What Was I Made For?"` |
| `fakeit_SongArtist` | Song artist | `"Queen"` | | `fakeit_SongArtist` | Song artist | `"Queen"` |
| `fakeit_SongGenre` | Song genre | `"Rock"` | | `fakeit_SongGenre` | Song genre | `"Rock"` |
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1789006805,
"narHash": "sha256-xB8mKMOx1IA9vTDNLmJZ6n4wCMq/cuWBBOzGCRnqxrU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8ce4ef6cb6f871616146b9fe26d2a5ae594e94fe",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "Sarin - high-performance HTTP load testing tool built with Go and fasthttp";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.rev or self.dirtyRev or "unknown";
# self.lastModifiedDate is "YYYYMMDDHHMMSS"; reshape to RFC 3339 (UTC).
raw = self.lastModifiedDate or "19700101000000";
s = i: n: lib.substring i n raw;
buildDate = "${s 0 4}-${s 4 2}-${s 6 2}T${s 8 2}:${s 10 2}:${s 12 2}Z";
in
{
packages = forAllSystems (pkgs: {
default = pkgs.callPackage ./nix/package.nix {
inherit rev buildDate;
};
});
# nix run github:aykhans/sarin/release -- <args>
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.default}/bin/sarin";
meta.description = "High-performance HTTP load testing tool";
};
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.go_1_27 pkgs.golangci-lint pkgs.go-task ];
};
});
# For downstream flakes: inputs.sarin.overlays.default
overlays.default = final: _prev: {
sarin = final.callPackage ./nix/package.nix { };
};
};
}
+27 -32
View File
@@ -1,58 +1,53 @@
module go.aykhans.me/sarin module go.aykhans.me/sarin
go 1.26.3 go 1.27.1
require ( require (
github.com/brianvoe/gofakeit/v7 v7.15.0 charm.land/bubbles/v2 v2.2.1
github.com/charmbracelet/bubbles v1.0.0 charm.land/bubbletea/v2 v2.0.10
github.com/charmbracelet/bubbletea v1.3.10 charm.land/glamour/v2 v2.0.1
github.com/charmbracelet/glamour v1.0.0 charm.land/lipgloss/v2 v2.0.6
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/brianvoe/gofakeit/v7 v7.17.1
github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.71.0 github.com/valyala/fasthttp v1.74.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.2
go.aykhans.me/utils v1.0.7 go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.4 go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/net v0.54.0 golang.org/x/net v0.59.0
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be // 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/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2/v2 v2.8.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.20.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/molecule-man/go-brrr v1.2.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v1.2.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.6 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/term v0.43.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.42.0 // indirect
) )
+62 -78
View File
@@ -1,124 +1,108 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs= github.com/brianvoe/gofakeit/v7 v7.17.1 h1:50FLBhTGVJQaj6ysRUu0it8wCdYO2uGM9VfuxI+csEc=
github.com/brianvoe/gofakeit/v7 v7.15.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/brianvoe/gofakeit/v7 v7.17.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd h1:W0HOPGkwRaAmV20plE/f+Rk6Xn+IdR8G0qHsGXcOnF8=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be h1:O22D2Od8gEsRGTDPKDTRzx2BGrvVcIAJlwBf+1sTeN0=
github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2/v2 v2.8.2 h1:uTTe+cxADL4be+dv4DZwsBIEPCQ6RHPlnVuKrtLqSx0=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.8.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913 h1:0haZHQSJqA4qFZvnKIRgekXrPo2sQ2oRvFemiNHhcp0=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913/go.mod h1:GrCNKUrVplXZj0juFue0Cj2Xt/eL2Nuu2+y3UTanEo8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d h1:kZK3FQM86gr3cgJHMP7rPn8A3Z75mt+ZaVBoqMlpPF0=
github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw= go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI= go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+40
View File
@@ -0,0 +1,40 @@
package config
import (
"flag"
"fmt"
"go.aykhans.me/sarin/internal/types"
"go.yaml.in/yaml/v4"
)
var (
_ flag.Value = (*byteSize)(nil)
_ yaml.Unmarshaler = (*byteSize)(nil)
)
// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB.
type byteSize uint64
func (size byteSize) String() string {
return types.FormatByteSize(uint64(size))
}
// Set fills the size from a command line value.
func (size *byteSize) Set(value string) error {
parsed, err := types.ParseByteSize(value)
if err != nil {
return err
}
*size = byteSize(parsed)
return nil
}
// UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line.
func (size *byteSize) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a size, but got %s", yamlNodeName(node))
}
return size.Set(node.Value)
}
+65 -42
View File
@@ -20,30 +20,33 @@ Simple usage:
Flags: Flags:
General Config: General Config:
-h, -help Help for sarin -h, -help Help for sarin
-v, -version Version for sarin -v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v) -s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL) -f, -config-file []string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d) -c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests -r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h) -d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
-q, -quiet bool Hide the progress bar and runtime logs (default %v) -l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v') -w, -log-file string Write runtime logs to this file instead of the terminal/stderr
-z, -dry-run bool Run without sending requests (default %v) -p, -progress string Progress display (possible values: bar, none) (default '%v')
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
-z, -dry-run bool Run without sending the generated requests (default %v)
Request Config: Request Config:
-U, -url string Target URL for the request -U, -url string Target URL for the request
-M, -method []string HTTP method for the request (default %s) -M, -method []string HTTP method for the request (default %s)
-B, -body []string Body for the request (e.g. "body text") -B, -body []string Body for the request (e.g. "body text")
-P, -param []string URL parameter for the request (e.g. "key1=value1") -P, -param []string URL parameter for the request (e.g. "key1=value1")
-H, -header []string Header for the request (e.g. "key1: value1") -H, -header []string Header for the request (e.g. "key1: value1")
-C, -cookie []string Cookie for the request (e.g. "key1=value1") -C, -cookie []string Cookie for the request (e.g. "key1=value1")
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080") -X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1") -V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v) -T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v) -S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
-lua []string Lua script for request transformation (inline or @file/@url) -I, -insecure bool Skip SSL/TLS certificate verification (default %v)
-js []string JavaScript script for request transformation (inline or @file/@url)` -lua []string Lua script for request transformation (inline or @file/@url)
-js []string JavaScript script for request transformation (inline or @file/@url)`
var _ IParser = ConfigCLIParser{} var _ IParser = ConfigCLIParser{}
@@ -88,23 +91,26 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
concurrency uint concurrency uint
requestCount uint64 requestCount uint64
duration time.Duration duration time.Duration
quiet bool logLevel string
logFile string
progress string
output string output string
dryRun bool dryRun bool
// Request config // Request config
urlInput string urlInput string
methods = stringSliceArg{} methods = stringSliceArg{}
bodies = stringSliceArg{} bodies = stringSliceArg{}
params = stringSliceArg{} params = stringSliceArg{}
headers = stringSliceArg{} headers = stringSliceArg{}
cookies = stringSliceArg{} cookies = stringSliceArg{}
proxies = stringSliceArg{} proxies = stringSliceArg{}
values = stringSliceArg{} values = stringSliceArg{}
timeout time.Duration timeout time.Duration
insecure bool maxResponseBody byteSize
luaScripts = stringSliceArg{} insecure bool
jsScripts = stringSliceArg{} luaScripts = stringSliceArg{}
jsScripts = stringSliceArg{}
) )
{ {
@@ -127,14 +133,20 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test") flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test") flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs") flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs") flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)")
flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)")
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)") flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)") flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
flagSet.BoolVar(&dryRun, "dry-run", 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 requests") flagSet.BoolVar(&dryRun, "z", false, "Run without sending the generated requests")
// Request config // Request config
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request") flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
@@ -164,6 +176,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)") flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
@@ -205,8 +220,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Requests = new(requestCount) config.Requests = new(requestCount)
case "duration", "d": case "duration", "d":
config.Duration = new(duration) config.Duration = new(duration)
case "quiet", "q": case "log-level", "l":
config.Quiet = new(quiet) config.LogLevel = new(logLevel)
case "log-file", "w":
config.LogFile = new(logFile)
case "progress", "p":
config.Progress = new(ConfigProgressType(progress))
case "output", "o": case "output", "o":
config.Output = new(ConfigOutputType(output)) config.Output = new(ConfigOutputType(output))
case "dry-run", "z": case "dry-run", "z":
@@ -244,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Values = append(config.Values, values...) config.Values = append(config.Values, values...)
case "timeout", "T": case "timeout", "T":
config.Timeout = new(timeout) config.Timeout = new(timeout)
case "max-response-body", "S":
config.MaxResponseBody = new(uint64(maxResponseBody))
case "insecure", "I": case "insecure", "I":
config.Insecure = new(insecure) config.Insecure = new(insecure)
case "lua": case "lua":
@@ -265,12 +286,14 @@ func (parser ConfigCLIParser) PrintHelp() {
cliUsageText+"\n", cliUsageText+"\n",
Defaults.ShowConfig, Defaults.ShowConfig,
Defaults.Concurrency, Defaults.Concurrency,
Defaults.Quiet, Defaults.LogLevel,
Defaults.Progress,
Defaults.Output, Defaults.Output,
Defaults.DryRun, Defaults.DryRun,
Defaults.Method, Defaults.Method,
Defaults.RequestTimeout, Defaults.RequestTimeout,
types.FormatByteSize(Defaults.MaxResponseBody),
Defaults.Insecure, Defaults.Insecure,
) )
} }
+199 -87
View File
@@ -6,17 +6,18 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/glamour" "charm.land/glamour/v2"
"github.com/charmbracelet/glamour/styles" "charm.land/glamour/v2/styles"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/term" "go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version" "go.aykhans.me/sarin/internal/version"
@@ -26,30 +27,35 @@ import (
) )
var Defaults = struct { var Defaults = struct {
UserAgent string UserAgent string
Method string Method string
RequestTimeout time.Duration RequestTimeout time.Duration
Concurrency uint MaxResponseBody uint64
ShowConfig bool Concurrency uint
Quiet bool ShowConfig bool
Insecure bool Progress ConfigProgressType
Output ConfigOutputType Insecure bool
DryRun bool Output ConfigOutputType
DryRun bool
LogLevel string
}{ }{
UserAgent: "Sarin/" + version.Version, UserAgent: "Sarin/" + version.Version,
Method: "GET", Method: "GET",
RequestTimeout: time.Second * 10, RequestTimeout: time.Second * 10,
Concurrency: 1, MaxResponseBody: 10 << 20, // 10 MiB
ShowConfig: false, Concurrency: 1,
Quiet: false, ShowConfig: false,
Insecure: false, Progress: ConfigProgressTypeBar,
Output: ConfigOutputTypeTable, Insecure: false,
DryRun: false, Output: ConfigOutputTypeTable,
DryRun: false,
LogLevel: "error",
} }
var ( var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"} ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"} ValidRequestURLSchemes = []string{"http", "https"}
ValidLogLevels = []string{"info", "error"}
) )
var ( var (
@@ -70,27 +76,37 @@ var (
ConfigOutputTypeNone ConfigOutputType = "none" ConfigOutputTypeNone ConfigOutputType = "none"
) )
type ConfigProgressType string
var (
ConfigProgressTypeBar ConfigProgressType = "bar"
ConfigProgressTypeNone ConfigProgressType = "none"
)
type Config struct { type Config struct {
ShowConfig *bool `yaml:"showConfig,omitempty"` ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"` Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"` Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"` URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"` Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"` MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"` Concurrency *uint `yaml:"concurrency,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"` Requests *uint64 `yaml:"requests,omitempty"`
Quiet *bool `yaml:"quiet,omitempty"` Duration *time.Duration `yaml:"duration,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"` Progress *ConfigProgressType `yaml:"progress,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"` Output *ConfigOutputType `yaml:"output,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"` Insecure *bool `yaml:"insecure,omitempty"`
Params types.Params `yaml:"params,omitempty"` DryRun *bool `yaml:"dryRun,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"` Params types.Params `yaml:"params,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"` Headers types.Headers `yaml:"headers,omitempty"`
Bodies []string `yaml:"bodies,omitempty"` Cookies types.Cookies `yaml:"cookies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"` Bodies []string `yaml:"bodies,omitempty"`
Values []string `yaml:"values,omitempty"` Proxies types.Proxies `yaml:"proxies,omitempty"`
Lua []string `yaml:"lua,omitempty"` Values []string `yaml:"values,omitempty"`
Js []string `yaml:"js,omitempty"` Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"`
LogLevel *string `yaml:"logLevel,omitempty"`
LogFile *string `yaml:"logFile,omitempty"`
} }
func (config Config) MarshalYAML() (any, error) { func (config Config) MarshalYAML() (any, error) {
@@ -164,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
if config.Timeout != nil { if config.Timeout != nil {
addField(content, "timeout", toNode(*config.Timeout), "") addField(content, "timeout", toNode(*config.Timeout), "")
} }
if config.MaxResponseBody != nil {
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
}
if config.Concurrency != nil { if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "") addField(content, "concurrency", toNode(*config.Concurrency), "")
} }
@@ -173,8 +192,8 @@ func (config Config) MarshalYAML() (any, error) {
if config.Duration != nil { if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "") addField(content, "duration", toNode(*config.Duration), "")
} }
if config.Quiet != nil { if config.Progress != nil {
addField(content, "quiet", toNode(*config.Quiet), "") addField(content, "progress", toNode(string(*config.Progress)), "")
} }
if config.Output != nil { if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "") addField(content, "output", toNode(string(*config.Output)), "")
@@ -221,6 +240,13 @@ func (config Config) MarshalYAML() (any, error) {
addStringSlice(content, "values", config.Values, false) addStringSlice(content, "values", config.Values, false)
addStringSlice(content, "lua", config.Lua, false) addStringSlice(content, "lua", config.Lua, false)
addStringSlice(content, "js", config.Js, false) addStringSlice(content, "js", config.Js, false)
if config.LogLevel != nil {
addField(content, "logLevel", toNode(*config.LogLevel), "")
}
if config.LogFile != nil {
addField(content, "logFile", toNode(*config.LogFile), "")
}
return root, nil return root, nil
} }
@@ -228,12 +254,12 @@ func (config Config) MarshalYAML() (any, error) {
func (config Config) Print() bool { func (config Config) Print() bool {
configYAML, err := yaml.Marshal(config) configYAML, err := yaml.Marshal(config)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error())) fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render("Error marshaling config to yaml: "+err.Error())))
os.Exit(1) os.Exit(1)
} }
// Pipe mode: output raw content directly // Pipe mode: output raw content directly
if !term.IsTerminal(os.Stdout.Fd()) { if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) {
fmt.Println(string(configYAML)) fmt.Println(string(configYAML))
os.Exit(0) os.Exit(0)
} }
@@ -247,25 +273,23 @@ func (config Config) Print() bool {
glamour.WithWordWrap(0), glamour.WithWordWrap(0),
) )
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error())) fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
os.Exit(1) os.Exit(1)
} }
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```") content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error())) fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
os.Exit(1) os.Exit(1)
} }
p := tea.NewProgram( p := tea.NewProgram(
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML}, printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
) )
m, err := p.Run() m, err := p.Run()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error())) fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
os.Exit(1) os.Exit(1)
} }
@@ -283,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
if newConfig.Timeout != nil { if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout config.Timeout = newConfig.Timeout
} }
if newConfig.MaxResponseBody != nil {
config.MaxResponseBody = newConfig.MaxResponseBody
}
if newConfig.Concurrency != nil { if newConfig.Concurrency != nil {
config.Concurrency = newConfig.Concurrency config.Concurrency = newConfig.Concurrency
} }
@@ -295,8 +322,8 @@ func (config *Config) Merge(newConfig *Config) {
if newConfig.ShowConfig != nil { if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig config.ShowConfig = newConfig.ShowConfig
} }
if newConfig.Quiet != nil { if newConfig.Progress != nil {
config.Quiet = newConfig.Quiet config.Progress = newConfig.Progress
} }
if newConfig.Output != nil { if newConfig.Output != nil {
config.Output = newConfig.Output config.Output = newConfig.Output
@@ -331,6 +358,12 @@ func (config *Config) Merge(newConfig *Config) {
if len(newConfig.Js) != 0 { if len(newConfig.Js) != 0 {
config.Js = append(config.Js, newConfig.Js...) config.Js = append(config.Js, newConfig.Js...)
} }
if newConfig.LogLevel != nil {
config.LogLevel = newConfig.LogLevel
}
if newConfig.LogFile != nil {
config.LogFile = newConfig.LogFile
}
} }
func (config *Config) SetDefaults() { func (config *Config) SetDefaults() {
@@ -355,14 +388,17 @@ func (config *Config) SetDefaults() {
if config.Timeout == nil { if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout config.Timeout = &Defaults.RequestTimeout
} }
if config.MaxResponseBody == nil {
config.MaxResponseBody = &Defaults.MaxResponseBody
}
if config.Concurrency == nil { if config.Concurrency == nil {
config.Concurrency = new(Defaults.Concurrency) config.Concurrency = new(Defaults.Concurrency)
} }
if config.ShowConfig == nil { if config.ShowConfig == nil {
config.ShowConfig = new(Defaults.ShowConfig) config.ShowConfig = new(Defaults.ShowConfig)
} }
if config.Quiet == nil { if config.Progress == nil {
config.Quiet = new(Defaults.Quiet) config.Progress = new(Defaults.Progress)
} }
if config.Insecure == nil { if config.Insecure == nil {
config.Insecure = new(Defaults.Insecure) config.Insecure = new(Defaults.Insecure)
@@ -377,6 +413,14 @@ func (config *Config) SetDefaults() {
if config.Output == nil { if config.Output == nil {
config.Output = new(Defaults.Output) config.Output = new(Defaults.Output)
} }
if config.LogLevel == nil {
config.LogLevel = new(Defaults.LogLevel)
}
if config.LogFile == nil {
config.LogFile = new("")
}
} }
// Validate validates the config fields. // Validate validates the config fields.
@@ -422,12 +466,33 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
} }
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
validationErrors = append(validationErrors, types.NewFieldValidationError(
"MaxResponseBody",
types.FormatByteSize(*config.MaxResponseBody),
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
))
}
if config.ShowConfig == nil { if config.ShowConfig == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
} }
if config.Quiet == nil { if config.Progress == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required")))
} else {
switch *config.Progress {
case ConfigProgressTypeBar, ConfigProgressTypeNone:
default:
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
"Progress",
string(*config.Progress),
fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone),
),
)
}
} }
if config.Output == nil { if config.Output == nil {
@@ -460,6 +525,36 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
} }
if config.LogLevel != nil {
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
if !slices.Contains(ValidLogLevels, level) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("LogLevel[%d]", i),
level,
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
),
)
}
}
}
if config.LogFile != nil && *config.LogFile != "" {
dir := filepath.Dir(*config.LogFile)
if info, err := os.Stat(dir); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
)
} else if !info.IsDir() {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
)
}
}
for i, proxy := range config.Proxies { for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) { if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append( validationErrors = append(
@@ -538,11 +633,11 @@ func ReadAllConfigs() *Config {
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error { utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp() cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
StyleYellow.Render( StyleYellow.Render(
"\nUnexpected CLI arguments provided: ", "\nUnexpected CLI arguments provided: ",
)+strings.Join(err.Args, ", "), )+strings.Join(err.Args, ", "),
) ))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
@@ -557,28 +652,35 @@ func ReadAllConfigs() *Config {
for _, configFile := range append(envConfig.Files, cliConf.Files...) { for _, configFile := range append(envConfig.Files, cliConf.Files...) {
fileConfig, err := parseConfigFile(configFile, 10) fileConfig, err := parseConfigFile(configFile, 10)
// A nested configFile fails under its own name, not the one given on the command line.
path := configFile.Path()
if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok {
path = configFileErr.Path
}
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ConfigFileReadError) error { utilsErr.OnType(func(err types.ConfigFileReadError) error {
cliParser.PrintHelp() cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
StyleYellow.Render( StyleYellow.Render(
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(), fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(),
), ),
) ))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnType(func(err types.UnmarshalError) error { utilsErr.OnType(func(err types.UnmarshalError) error {
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
StyleYellow.Render( StyleYellow.Render(
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(), fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(),
), ),
) ))
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnType(func(err types.FieldParseErrors) error { utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...) printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...)
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
@@ -594,7 +696,7 @@ func ReadAllConfigs() *Config {
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels. // parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
// Returns the merged configuration or an error if parsing fails. // Returns the merged configuration or an error if parsing fails.
// It can return the following errors: // It can return the following errors, each wrapped in a types.ConfigFileError naming the file:
// - types.ConfigFileReadError // - types.ConfigFileReadError
// - types.UnmarshalError // - types.UnmarshalError
// - types.FieldParseErrors // - types.FieldParseErrors
@@ -602,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
configFileParser := NewConfigFileParser(configFile) configFileParser := NewConfigFileParser(configFile)
fileConfig, err := configFileParser.Parse() fileConfig, err := configFileParser.Parse()
if err != nil { if err != nil {
return nil, err return nil, types.NewConfigFileError(configFile.Path(), err)
} }
if maxDepth <= 0 { if maxDepth <= 0 {
@@ -640,7 +742,7 @@ func validateScriptSource(script string) error {
return types.ErrScriptEmpty return types.ErrScriptEmpty
} }
// Not a file/URL reference - it's an inline script // Not a file/URL reference, so it's an inline script
if !strings.HasPrefix(script, "@") { if !strings.HasPrefix(script, "@") {
return nil return nil
} }
@@ -650,7 +752,7 @@ func validateScriptSource(script string) error {
return nil return nil
} }
// It's a file or URL reference - validate the source // It's a file or URL reference, so validate the source
source := script[1:] // Remove the @ prefix source := script[1:] // Remove the @ prefix
if source == "" { if source == "" {
@@ -669,20 +771,20 @@ func validateScriptSource(script string) error {
return nil return nil
} }
// It's a file path - basic validation (not empty, checked above) // It's a file path, already checked to be non-empty above
return nil return nil
} }
func printParseErrors(parserName string, errors ...types.FieldParseError) { func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors { for _, fieldErr := range errors {
if fieldErr.Value == "" { if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(), StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
) ))
} else { } else {
fmt.Fprintln(os.Stderr, fmt.Fprint(os.Stderr, lipgloss.Sprintln(
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(), StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
) ))
} }
} }
} }
@@ -723,7 +825,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
return m, tea.Quit return m, tea.Quit
@@ -746,13 +848,22 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m printConfigModel) View() string { func (m printConfigModel) View() tea.View {
// AltScreen and MouseMode were program options in bubbletea v1, in v2 they
// are view properties, so every return path has to declare them.
newView := func(s string) tea.View {
v := tea.NewView(s)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
if !m.ready { if !m.ready {
return "\n Initializing..." return newView("\n Initializing...")
} }
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar()) content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()) return newView(fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()))
} }
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) { func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
@@ -772,12 +883,13 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
width := msg.Width - scrollbarWidth width := msg.Width - scrollbarWidth
if !m.ready { if !m.ready {
m.viewport = viewport.New(width, height) m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
m.viewport.SetHorizontalStep(0)
m.viewport.SetContent(m.contentWithLineNumbers()) m.viewport.SetContent(m.contentWithLineNumbers())
m.ready = true m.ready = true
} else { } else {
m.viewport.Width = width m.viewport.SetWidth(width)
m.viewport.Height = height m.viewport.SetHeight(height)
} }
} }
@@ -792,12 +904,12 @@ func (m printConfigModel) headerView() string {
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit") printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
title = printConfigHelpStyle.Render(help) title = printConfigHelpStyle.Render(help)
} }
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title))) line := strings.Repeat("─", max(0, m.viewport.Width()+scrollbarWidth-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line) return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
} }
func (m printConfigModel) footerView() string { func (m printConfigModel) footerView() string {
return strings.Repeat("─", m.viewport.Width+scrollbarWidth) return strings.Repeat("─", m.viewport.Width()+scrollbarWidth)
} }
func (m printConfigModel) contentWithLineNumbers() string { func (m printConfigModel) contentWithLineNumbers() string {
@@ -819,7 +931,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
} }
func (m printConfigModel) scrollbar() string { func (m printConfigModel) scrollbar() string {
height := m.viewport.Height height := m.viewport.Height()
trackHeight := height - scrollbarBottomSpace trackHeight := height - scrollbarBottomSpace
totalLines := m.viewport.TotalLineCount() totalLines := m.viewport.TotalLineCount()
+90 -78
View File
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Files = append(config.Files, *types.ParseConfigFile(configFile)) config.Files = append(config.Files, *types.ParseConfigFile(configFile))
} }
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" { if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency) concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil { if err != nil {
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
} }
} }
if timeout := parser.getEnv("TIMEOUT"); timeout != "" { if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout) config.LogLevel = new(logLevel)
}
if logFile := parser.getEnv("LOG_FILE"); logFile != "" {
config.LogFile = new(logFile)
}
if progress := parser.getEnv("PROGRESS"); progress != "" {
config.Progress = new(ConfigProgressType(progress))
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil { if err != nil {
fieldParseErrors = append( fieldParseErrors = append(
fieldParseErrors, fieldParseErrors,
types.NewFieldParseError( types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"), parser.getFullEnvName("DRY_RUN"),
timeout, dryRun,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"), errors.New("invalid value for boolean, expected 'true' or 'false'"),
), ),
) )
} else { } else {
config.Timeout = &timeoutParsed config.DryRun = &dryRunParsed
} }
} }
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if param := parser.getEnv("PARAM"); param != "" { if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param) config.Params.Parse(param)
} }
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Cookies.Parse(cookie) config.Cookies.Parse(cookie)
} }
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if proxy := parser.getEnv("PROXY"); proxy != "" { if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy) err := config.Proxies.Parse(proxy)
if err != nil { if err != nil {
@@ -215,6 +179,54 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Values = []string{values} config.Values = []string{values}
} }
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("MAX_RESPONSE_BODY"),
maxResponseBody,
err,
),
)
} else {
config.MaxResponseBody = &maxResponseBodyParsed
}
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if lua := parser.getEnv("LUA"); lua != "" { if lua := parser.getEnv("LUA"); lua != "" {
config.Lua = []string{lua} config.Lua = []string{lua}
} }
+282 -56
View File
@@ -1,13 +1,17 @@
package config package config
import ( import (
"bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strconv"
"strings" "strings"
"time" "time"
@@ -79,10 +83,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, types.NewHTTPFetchError(url, err) return nil, types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return data, nil return data, nil
} }
@@ -126,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
*ss = slice *ss = slice
return nil return nil
default: default:
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind) return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
} }
} }
@@ -147,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
// Handle sequence of maps: [{key1: value1}, {key2: value2}] // Handle sequence of maps: [{key1: value1}, {key2: value2}]
for _, item := range node.Content { for _, item := range node.Content {
if item.Kind != yaml.MappingNode { if item.Kind != yaml.MappingNode {
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind) return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
} }
if err := kv.unmarshalMapping(item); err != nil { if err := kv.unmarshalMapping(item); err != nil {
return err return err
@@ -155,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
} }
return nil return nil
default: default:
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind) return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
} }
} }
@@ -166,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
valueNode := node.Content[i+1] valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode { if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind) return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
} }
key := keyNode.Value key := keyNode.Value
@@ -178,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
case yaml.SequenceNode: case yaml.SequenceNode:
for _, v := range valueNode.Content { for _, v := range valueNode.Content {
if v.Kind != yaml.ScalarNode { if v.Kind != yaml.ScalarNode {
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind) return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
} }
values = append(values, v.Value) values = append(values, v.Value)
} }
default: default:
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind) return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
} }
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values}) *kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
@@ -192,26 +204,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
} }
type configYAML struct { type configYAML struct {
ConfigFiles stringOrSliceField `yaml:"configFile"` ShowConfig *bool `yaml:"showConfig"`
Method stringOrSliceField `yaml:"method"` ConfigFiles stringOrSliceField `yaml:"configFile"`
URL *string `yaml:"url"` Concurrency *uint `yaml:"concurrency"`
Timeout *time.Duration `yaml:"timeout"` RequestCount *uint64 `yaml:"requests"`
Concurrency *uint `yaml:"concurrency"` Duration *time.Duration `yaml:"duration"`
RequestCount *uint64 `yaml:"requests"` LogLevel *string `yaml:"logLevel"`
Duration *time.Duration `yaml:"duration"` LogFile *string `yaml:"logFile"`
Quiet *bool `yaml:"quiet"` Progress *string `yaml:"progress"`
Output *string `yaml:"output"` Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"` DryRun *bool `yaml:"dryRun"`
ShowConfig *bool `yaml:"showConfig"` URL *string `yaml:"url"`
DryRun *bool `yaml:"dryRun"` Method stringOrSliceField `yaml:"method"`
Params keyValuesField `yaml:"params"` Bodies stringOrSliceField `yaml:"body"`
Headers keyValuesField `yaml:"headers"` Params keyValuesField `yaml:"params"`
Cookies keyValuesField `yaml:"cookies"` Headers keyValuesField `yaml:"headers"`
Bodies stringOrSliceField `yaml:"body"` Cookies keyValuesField `yaml:"cookies"`
Proxies stringOrSliceField `yaml:"proxy"` Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"` Values stringOrSliceField `yaml:"values"`
Lua stringOrSliceField `yaml:"lua"` Timeout *time.Duration `yaml:"timeout"`
Js stringOrSliceField `yaml:"js"` MaxResponseBody *byteSize `yaml:"maxResponseBody"`
Insecure *bool `yaml:"insecure"`
Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"`
} }
// ParseYAML parses YAML config file arguments into a Config object. // ParseYAML parses YAML config file arguments into a Config object.
@@ -224,40 +239,31 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
parsedData = &configYAML{} parsedData = &configYAML{}
) )
err := yaml.Unmarshal(data, &parsedData) decoder := yaml.NewDecoder(bytes.NewReader(data))
if err != nil { decoder.KnownFields(true)
return nil, types.NewUnmarshalError(err) // The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
return nil, types.NewUnmarshalError(yamlError(err))
}
// Decode reads one document, so anything after the first "---" would be dropped silently.
for {
var next yaml.Node
err := decoder.Decode(&next)
if err == io.EOF { //nolint:errorlint
break
}
if err != nil {
return nil, types.NewUnmarshalError(yamlError(err))
}
if !isEmptyDocument(&next) {
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
}
} }
var fieldParseErrors []types.FieldParseError var fieldParseErrors []types.FieldParseError
config.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.ShowConfig = parsedData.ShowConfig config.ShowConfig = parsedData.ShowConfig
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = new(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(parsedData.ConfigFiles) > 0 { if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles { for _, configFile := range parsedData.ConfigFiles {
@@ -265,6 +271,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
} }
} }
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.LogLevel = parsedData.LogLevel
config.LogFile = parsedData.LogFile
if parsedData.Progress != nil {
config.Progress = new(ConfigProgressType(*parsedData.Progress))
}
if parsedData.Output != nil {
config.Output = new(ConfigOutputType(*parsedData.Output))
}
config.DryRun = parsedData.DryRun
if parsedData.URL != nil { if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL) urlParsed, err := url.Parse(*parsedData.URL)
if err != nil { if err != nil {
@@ -274,6 +296,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
} }
} }
config.Methods = append(config.Methods, parsedData.Method...)
config.Bodies = append(config.Bodies, parsedData.Bodies...)
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
for i, proxy := range parsedData.Proxies { for i, proxy := range parsedData.Proxies {
err := config.Proxies.Parse(proxy) err := config.Proxies.Parse(proxy)
if err != nil { if err != nil {
@@ -284,9 +318,201 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
} }
} }
config.Values = append(config.Values, parsedData.Values...)
config.Timeout = parsedData.Timeout
if parsedData.MaxResponseBody != nil {
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
}
config.Insecure = parsedData.Insecure
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(fieldParseErrors) > 0 { if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors) return nil, types.NewFieldParseErrors(fieldParseErrors)
} }
return config, nil return config, nil
} }
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
func isEmptyDocument(node *yaml.Node) bool {
if len(node.Content) == 0 {
return true
}
root := node.Content[0]
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
}
// yamlError rewrites yaml decode errors so they read like config file errors.
// Errors from building the config arrive collected, everything earlier arrives on its own.
func yamlError(err error) error {
var loadErrors *yaml.LoadErrors
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
var builder strings.Builder
for i, loadError := range loadErrors.Errors {
if i > 0 {
builder.WriteString("\n")
}
builder.WriteString(yamlErrorText(loadError))
}
return errors.New(builder.String())
}
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
return errors.New(yamlErrorText(loadError))
}
return err
}
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
// The mark is where reading gave up and the context mark starts the construct that broke.
func yamlErrorText(loadError *yaml.LoadError) string {
var builder strings.Builder
if loadError.Mark.Line > 0 {
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
}
if loadError.ContextMsg != "" {
builder.WriteString(loadError.ContextMsg + ": ")
}
builder.WriteString(yamlErrorMessage(loadError.Message))
start := loadError.ContextMark
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
}
return builder.String()
}
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
var yamlTypeNames = map[string]string{
"!!str": "text",
"!!int": "a number",
"!!float": "a number",
"!!bool": "true or false",
"!!seq": "a list",
"!!map": "a mapping",
"!!null": "an empty value",
"!!timestamp": "a date",
"!!binary": "binary data",
"string": "text",
"bool": "true or false",
"uint": "a whole number of 0 or more",
"uint64": "a whole number of 0 or more",
"time.Duration": "a duration such as 10s",
configYAMLTypeName: "a mapping of config keys",
}
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
func yamlNodeName(node *yaml.Node) string {
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
return name
}
if node.Tag == "" {
return yamlKindName(node.Kind)
}
return yamlKindName(node.Kind) + " tagged " + node.Tag
}
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
func yamlTagKind(tag string) yaml.Kind {
switch tag {
case "!!seq":
return yaml.SequenceNode
case "!!map":
return yaml.MappingNode
default:
return yaml.ScalarNode
}
}
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
func yamlKindName(kind yaml.Kind) string {
switch kind {
case yaml.SequenceNode:
return "a list"
case yaml.MappingNode:
return "a mapping"
case yaml.AliasNode:
return "an alias"
default:
return "a value"
}
}
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
func yamlErrorMessage(message string) string {
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
if name, ok := strings.CutPrefix(field, "field "); ok {
return fmt.Sprintf("unknown key %q", name)
}
}
// The user controls the value, so the rightmost separator is the only one that is ours.
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
at, width, tagged := -1, 0, false
for index, separator := range []string{" into ", " as a "} {
if found := strings.LastIndex(names, separator); found > at {
at, width, tagged = found, len(separator), index == 1
}
}
if at >= 0 {
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
if tagged {
return fmt.Sprintf("%s is not %s", got, want)
}
return fmt.Sprintf("expected %s, but got %s", want, got)
}
}
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
}
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
func yamlWantedName(text string) string {
if name, ok := yamlTypeNames[text]; ok {
return name
}
return text
}
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
func yamlGotName(text string) string {
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
for _, tag := range []string{"!!seq", "!!map"} {
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
return yamlValueText(value) + " tagged " + tag
}
}
tag, value, _ := strings.Cut(text, " ")
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
name, known := yamlTypeNames[tag]
switch {
case known && value != "":
return yamlValueText(value)
case known:
return name
case value != "":
return yamlValueText(value) + " tagged " + tag
default:
return "a value tagged " + tag
}
}
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
func yamlValueText(value string) string {
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
return "a value starting with " + strconv.Quote(start)
}
return strconv.Quote(value)
}
+1 -1
View File
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
func ValidateTemplates(config *Config) []types.FieldValidationError { func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package // Create template function map using the same functions as sarin package
// Use nil for fileCache during validation - templates are only parsed, not executed // fileCache is nil because templates are only parsed here, not executed
randSource := sarin.NewDefaultRandSource() randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil) funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
+205 -96
View File
@@ -5,26 +5,102 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"math"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice" utilsSlice "go.aykhans.me/utils/slice"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )
type HostClientGenerator func() *fasthttp.HostClient // clientIndexGenerator returns the index of the host client (and proxy) to use next.
type clientIndexGenerator func() int
func safeUintToInt(u uint) int { // preferIPv4Resolver hides a host's IPv6 addresses whenever it also has IPv4 ones.
if u > math.MaxInt { type preferIPv4Resolver struct{}
return math.MaxInt
func (preferIPv4Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
return lookupPreferred(ctx, host, true)
}
// preferIPv6Resolver is the mirror of preferIPv4Resolver, used for the fallback dial.
type preferIPv6Resolver struct{}
func (preferIPv6Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
return lookupPreferred(ctx, host, false)
}
// lookupPreferred resolves host and keeps only the wanted family, unless that family is absent.
func lookupPreferred(ctx context.Context, host string, wantIPv4 bool) ([]net.IPAddr, error) {
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
preferred := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
if (ip.IP.To4() != nil) == wantIPv4 {
preferred = append(preferred, ip)
}
}
if len(preferred) > 0 {
return preferred, nil
}
return ips, nil
}
// preferIPv4DialTimeoutShare leaves half the timeout for the IPv6 retry.
const preferIPv4DialTimeoutShare = 2
// preferIPv4Dialer dials over IPv4 and retries over IPv6 alone when that fails.
type preferIPv4Dialer struct {
ipv4 *fasthttp.TCPDialer
ipv6 *fasthttp.TCPDialer
}
// dialerConcurrency matches the limit on fasthttp own package-level dialer.
const dialerConcurrency = 1000
// dialer is shared so all clients use one DNS cache.
var dialer = &preferIPv4Dialer{
ipv4: &fasthttp.TCPDialer{Resolver: preferIPv4Resolver{}, Concurrency: dialerConcurrency},
ipv6: &fasthttp.TCPDialer{Resolver: preferIPv6Resolver{}, Concurrency: dialerConcurrency},
}
// DialTimeout dials addr, which must carry a port.
func (d *preferIPv4Dialer) DialTimeout(addr string, timeout time.Duration) (net.Conn, error) {
// fasthttp passes 0 from its pooled dial path.
if timeout <= 0 {
timeout = fasthttp.DefaultDialTimeout
}
deadline := time.Now().Add(timeout)
conn, err := d.ipv4.DialDualStackTimeout(addr, timeout/preferIPv4DialTimeoutShare)
if err == nil {
return conn, nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, err
}
conn, fallbackErr := d.ipv6.DialDualStackTimeout(addr, remaining)
if fallbackErr != nil {
// Report the IPv4 error, not the fallback one.
return nil, err
}
return conn, nil
}
// dialFunc fills in the default port, which fasthttp skips for a custom dial func.
func (d *preferIPv4Dialer) dialFunc(isTLS bool) fasthttp.DialFuncWithTimeout {
return func(addr string, timeout time.Duration) (net.Conn, error) {
return d.DialTimeout(fasthttp.AddMissingPort(addr, isTLS), timeout)
} }
return int(u)
} }
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies. // NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
@@ -38,6 +114,7 @@ func NewHostClients(
maxConns uint, maxConns uint,
requestURL *url.URL, requestURL *url.URL,
skipVerify bool, skipVerify bool,
maxResponseBody uint64,
) ([]*fasthttp.HostClient, error) { ) ([]*fasthttp.HostClient, error) {
isTLS := requestURL.Scheme == "https" isTLS := requestURL.Scheme == "https"
@@ -66,6 +143,9 @@ func NewHostClients(
MaxConnDuration: timeout, MaxConnDuration: timeout,
WriteTimeout: timeout, WriteTimeout: timeout,
ReadTimeout: timeout, ReadTimeout: timeout,
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
MaxIdemponentCallAttempts: 1,
ReadBufferSize: 64 << 10, // 64 KiB
DisableHeaderNamesNormalizing: true, DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true, DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true, NoDefaultUserAgentHeader: true,
@@ -77,8 +157,9 @@ func NewHostClients(
} }
client := &fasthttp.HostClient{ client := &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns), MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS, IsTLS: isTLS,
DialTimeout: dialer.dialFunc(isTLS),
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec InsecureSkipVerify: skipVerify, //nolint:gosec
}, },
@@ -87,6 +168,9 @@ func NewHostClients(
MaxConnDuration: timeout, MaxConnDuration: timeout,
WriteTimeout: timeout, WriteTimeout: timeout,
ReadTimeout: timeout, ReadTimeout: timeout,
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
MaxIdemponentCallAttempts: 1,
ReadBufferSize: 64 << 10, // 64 KiB
DisableHeaderNamesNormalizing: true, DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true, DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true, NoDefaultUserAgentHeader: true,
@@ -94,40 +178,45 @@ func NewHostClients(
return []*fasthttp.HostClient{client}, nil return []*fasthttp.HostClient{client}, nil
} }
// NewProxyDialFunc creates a dial function for the given proxy URL. // NewProxyDialFunc creates a dial function for the given proxy URL with a fixed timeout.
// It can return the following errors: // It can return the following errors:
// - types.ProxyUnsupportedSchemeError // - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) { func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var ( dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
dialer fasthttp.DialFunc if err != nil {
err error return nil, err
) }
return func(addr string) (net.Conn, error) {
return dial(addr, timeout)
}, nil
}
// NewProxyDialFuncWithTimeout creates a dial function for the given proxy URL that takes the timeout per call.
// It can return the following errors:
// - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fasthttp.DialFuncWithTimeout, error) {
switch proxyURL.Scheme { switch proxyURL.Scheme {
case "socks5": case "socks5":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true) return fasthttpSocksDialer(ctx, proxyURL, true)
if err != nil {
return nil, err
}
case "socks5h": case "socks5h":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false) return fasthttpSocksDialer(ctx, proxyURL, false)
if err != nil {
return nil, err
}
case "http": case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout) return fasthttpConnectDialer(proxyURL, false), nil
case "https": case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout) return fasthttpConnectDialer(proxyURL, true), nil
default: default:
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme) return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
} }
return dialer, nil
} }
// The returned dial function can return the following errors: // fasthttpSocksDialer creates a SOCKS5 dial function that takes the timeout per call.
// It can return the following errors:
// - types.ErrProxyNoContextDialer
// - types.ProxyDialError // - types.ProxyDialError
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) { func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally bool) (fasthttp.DialFuncWithTimeout, error) {
netDialer := &net.Dialer{} netDialer := &net.Dialer{}
// Parse auth from proxy URL if present // Parse auth from proxy URL if present
@@ -149,66 +238,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
proxyStr := proxyURL.String() proxyStr := proxyURL.String()
// Assert to ContextDialer for timeout support // Timeouts need DialContext, which proxy.SOCKS5 always provides with a net.Dialer.
contextDialer, ok := socksDialer.(proxy.ContextDialer) contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok { if !ok {
// Fallback without timeout (should not happen with net.Dialer) return nil, types.ErrProxyNoContextDialer
return func(addr string) (net.Conn, error) {
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil
} }
// Return dial function that uses context with timeout // Return dial function that uses context with timeout
return func(addr string) (net.Conn, error) { return func(addr string, timeout time.Duration) (net.Conn, error) {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
addrs := []string{addr}
if resolveLocally { if resolveLocally {
host, port, err := net.SplitHostPort(addr) var err error
addrs, err = resolveIPv4First(ctx, addr, timeout)
if err != nil { if err != nil {
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
}
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout) // Try every resolved address, so one unreachable family does not fail the dial.
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host) var lastErr error
dnsCancel() for _, candidate := range addrs {
if err != nil { remaining := time.Until(deadline)
return nil, types.NewProxyDialError(proxyStr, err) if remaining <= 0 {
} return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
if len(ips) == 0 {
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
} }
// Use the first resolved IP dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
addr = net.JoinHostPort(ips[0].String(), port) conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
dialCancel()
if err == nil {
return conn, nil
}
lastErr = err
} }
return nil, types.NewProxyDialError(proxyStr, lastErr)
// Use remaining time for dial
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
}
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel()
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil }, nil
} }
// resolveIPv4First resolves the host of addr into candidate addresses, IPv4 ones first.
// It can return the following errors:
// - types.ProxyResolveError
func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, types.NewProxyResolveError(host)
}
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
if ip.To4() != nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
for _, ip := range ips {
if ip.To4() == nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
return addrs, nil
}
// fasthttpConnectDialer creates a dial function that tunnels through an HTTP proxy with CONNECT,
// reaching the proxy over TLS when useTLS is set.
//
// The returned dial function can return the following errors: // The returned dial function can return the following errors:
// - types.ProxyDialError // - types.ProxyDialError
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc { func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout {
defaultPort := "80"
if useTLS {
defaultPort = "443"
}
proxyAddr := proxyURL.Host proxyAddr := proxyURL.Host
if proxyURL.Port() == "" { if proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443") proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
} }
// Build Proxy-Authorization header if auth is present // Build Proxy-Authorization header if auth is present
@@ -222,10 +337,10 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
proxyStr := proxyURL.String() proxyStr := proxyURL.String()
return func(addr string) (net.Conn, error) { return func(addr string, timeout time.Duration) (net.Conn, error) {
// Establish TCP connection to proxy with timeout // Establish TCP connection to proxy with timeout
start := time.Now() start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout) conn, err := dialer.DialTimeout(proxyAddr, timeout)
if err != nil { if err != nil {
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
@@ -242,13 +357,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Upgrade to TLS if useTLS {
tlsConn := tls.Client(conn, &tls.Config{ tlsConn := tls.Client(conn, &tls.Config{
ServerName: proxyURL.Hostname(), ServerName: proxyURL.Hostname(),
}) })
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec tlsConn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
}
conn = tlsConn
} }
// Build and send CONNECT request // Build and send CONNECT request
@@ -262,35 +379,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
connectReq.Header.Set("Proxy-Authorization", proxyAuth) connectReq.Header.Set("Proxy-Authorization", proxyAuth)
} }
if err := connectReq.Write(tlsConn); err != nil { if err := connectReq.Write(conn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Read response using buffered reader, but return wrapped connection // Read response using buffered reader, but return wrapped connection
// to preserve any buffered data // to preserve any buffered data
bufReader := bufio.NewReader(tlsConn) bufReader := bufio.NewReader(conn)
resp, err := http.ReadResponse(bufReader, connectReq) resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil { if err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
resp.Body.Close() //nolint:errcheck,gosec resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status)) return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
} }
// Clear deadline for the tunneled connection // Clear deadline for the tunneled connection
if err := tlsConn.SetDeadline(time.Time{}); err != nil { if err := conn.SetDeadline(time.Time{}); err != nil {
tlsConn.Close() //nolint:errcheck,gosec conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Return wrapped connection that uses the buffered reader // Return wrapped connection that uses the buffered reader
// to avoid losing any data that was read ahead // to avoid losing any data that was read ahead
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil return &bufferedConn{Conn: conn, reader: bufReader}, nil
} }
} }
@@ -306,18 +423,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b) return c.reader.Read(b)
} }
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator { func newClientIndexGenerator(count int) clientIndexGenerator {
switch len(clients) { indexes := make([]int, count)
case 0: for i := range indexes {
hostClient := &fasthttp.HostClient{} indexes[i] = i
return func() *fasthttp.HostClient {
return hostClient
}
case 1:
return func() *fasthttp.HostClient {
return clients[0]
}
default:
return utilsSlice.RandomCycle(nil, clients...)
} }
return utilsSlice.RandomCycle(nil, indexes...)
} }
+10 -3
View File
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
file := &CachedFile{Content: content, Filename: filename} file := &CachedFile{Content: content, Filename: filename}
// LoadOrStore handles race condition - if another goroutine // If another goroutine cached it first, LoadOrStore returns theirs
// cached it first, we get theirs (no duplicate storage)
actual, _ := fc.cache.LoadOrStore(source, file) actual, _ := fc.cache.LoadOrStore(source, file)
return actual.(*CachedFile), nil return actual.(*CachedFile), nil
} }
@@ -94,10 +93,18 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
content, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, "", types.NewHTTPFetchError(url, err) return nil, "", types.NewHTTPFetchError(url, err)
} }
if len(content) > types.RemoteFetchLimit {
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
// Extract filename from URL path // Extract filename from URL path
filename := path.Base(url) filename := path.Base(url)
+15
View File
@@ -1,6 +1,7 @@
package sarin package sarin
import ( import (
"math"
"math/rand/v2" "math/rand/v2"
"time" "time"
) )
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
} }
return values[0] return values[0]
} }
func safeUintToInt(u uint) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
func safeUint64ToInt(u uint64) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
+94 -37
View File
@@ -56,9 +56,9 @@ func NewRequestGenerator(
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot) pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot) paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot) headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot) cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
var bodyTemplateRoot *template.Template var bodyTemplateRoot *template.Template
@@ -83,13 +83,26 @@ func NewRequestGenerator(
Cookies: make(map[string][]string), Cookies: make(map[string][]string),
} }
// When a map's key set is fixed, every request refills exactly the same keys, so
// the value slices can be truncated and reused instead of being reallocated after
// a clear(). Scripts are excluded: they may swap the maps out or add keys of their
// own, and they would observe a leftover empty slice where a key used to be absent.
reuseParamSlices := paramKeysAreStatic && !hasScripts
reuseHeaderSlices := headerKeysAreStatic && !hasScripts
reuseCookieSlices := cookieKeysAreStatic && !hasScripts
var ( var (
data valuesData data valuesData
path string path string
err error err error
) )
return func(req *fasthttp.Request) error { return func(req *fasthttp.Request) error {
resetRequestData(reqData) resetStringSliceMap(reqData.Headers, reuseHeaderSlices)
resetStringSliceMap(reqData.Params, reuseParamSlices)
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
reqData.Method = ""
reqData.Path = ""
reqData.Body = ""
data, err = valuesGenerator() data, err = valuesGenerator()
if err != nil { if err != nil {
@@ -143,20 +156,24 @@ func NewRequestGenerator(
hasScripts hasScripts
} }
func resetRequestData(reqData *script.RequestData) { // resetStringSliceMap empties m for the next render. With reuse it truncates the
reqData.Method = "" // value slices in place so the following appends land in the existing backing
reqData.Path = "" // arrays, without it the map is cleared so no stale key can survive.
reqData.Body = "" func resetStringSliceMap(m map[string][]string, reuse bool) {
clear(reqData.Headers) if !reuse {
clear(reqData.Params) clear(m)
clear(reqData.Cookies) return
}
for k, v := range m {
m[k] = v[:0]
}
} }
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) { func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
req.Header.SetHost(host) req.Header.SetHost(host)
req.SetRequestURI(reqData.Path) req.SetRequestURI(reqData.Path)
req.Header.SetMethod(reqData.Method) req.Header.SetMethod(reqData.Method)
req.SetBody([]byte(reqData.Body)) req.SetBodyString(reqData.Body)
for k, values := range reqData.Headers { for k, values := range reqData.Headers {
for _, v := range values { for _, v := range values {
@@ -164,20 +181,17 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
} }
} }
for k, values := range reqData.Params { if len(reqData.Params) > 0 {
for _, v := range values { args := req.URI().QueryArgs()
req.URI().QueryArgs().Add(k, v) for k, values := range reqData.Params {
for _, v := range values {
args.Add(k, v)
}
} }
} }
if len(reqData.Cookies) > 0 { if len(reqData.Cookies) > 0 {
cookieStrings := make([]string, 0, len(reqData.Cookies)) req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
for k, values := range reqData.Cookies {
for _, v := range values {
cookieStrings = append(cookieStrings, k+"="+v)
}
}
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
} }
if scheme == "https" { if scheme == "https" {
@@ -185,6 +199,22 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
} }
} }
// cookieHeaderValue joins cookies into a single Cookie header value ("a=1; b=2").
func cookieHeaderValue(cookies map[string][]string) string {
var sb strings.Builder
for k, values := range cookies {
for _, v := range values {
if sb.Len() > 0 {
sb.WriteString("; ")
}
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(v)
}
}
return sb.String()
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot) methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
@@ -221,8 +251,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
}, isDynamic }, isDynamic
} }
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot) generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot)
var ( var (
key, value string key, value string
@@ -243,11 +273,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
reqData.Params[key] = append(reqData.Params[key], value) reqData.Params[key] = append(reqData.Params[key], value)
} }
return nil return nil
}, isDynamic }, isDynamic, keysAreStatic
} }
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot) generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot)
var ( var (
key, value string key, value string
@@ -268,11 +298,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
reqData.Headers[key] = append(reqData.Headers[key], value) reqData.Headers[key] = append(reqData.Headers[key], value)
} }
return nil return nil
}, isDynamic }, isDynamic, keysAreStatic
} }
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot) generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
var ( var (
key, value string key, value string
@@ -293,14 +323,26 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
reqData.Cookies[key] = append(reqData.Cookies[key], value) reqData.Cookies[key] = append(reqData.Cookies[key], value)
} }
return nil return nil
}, isDynamic }, isDynamic, keysAreStatic
} }
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) { func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
// No values configured: hand back one shared empty map instead of allocating a
// fresh one for every request. Nothing ever writes to it.
if len(values) == 0 {
empty := valuesData{Values: map[string]string{}}
return func() (valuesData, error) { return empty, nil }
}
generators := make([]func(_ any) (string, error), len(values)) generators := make([]func(_ any) (string, error), len(values))
isDynamic := false
for i, v := range values { for i, v := range values {
generators[i], _ = createTemplateFunc(v, lazyRoot) var valueIsDynamic bool
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
if valueIsDynamic {
isDynamic = true
}
} }
var ( var (
@@ -308,7 +350,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
data map[string]string data map[string]string
err error err error
) )
return func() (valuesData, error) { generate := func() (valuesData, error) {
result := make(map[string]string) result := make(map[string]string)
for _, generator := range generators { for _, generator := range generators {
rendered, err = generator(nil) rendered, err = generator(nil)
@@ -326,6 +368,15 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
return valuesData{Values: result}, nil return valuesData{Values: result}, nil
} }
// Every value is a literal, so the parsed result is identical on every request:
// render and parse once instead of re-running godotenv per request.
if !isDynamic {
staticResult, staticErr := generate()
return func() (valuesData, error) { return staticResult, staticErr }
}
return generate
} }
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) { func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
@@ -335,15 +386,19 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
tmpl, err := lazyRoot().New("").Parse(value) tmpl, err := lazyRoot().New("").Parse(value)
if err == nil && hasTemplateActions(tmpl) { if err == nil && hasTemplateActions(tmpl) {
var err error var (
buf bytes.Buffer
err error
)
return func(data any) (string, error) { return func(data any) (string, error) {
var buf bytes.Buffer buf.Reset()
if err = tmpl.Execute(&buf, data); err != nil { if err = tmpl.Execute(&buf, data); err != nil {
return "", types.NewTemplateRenderError(err) return "", types.NewTemplateRenderError(err)
} }
return buf.String(), nil return buf.String(), nil
}, true }, true
} }
return func(_ any) (string, error) { return value, nil }, false return func(_ any) (string, error) { return value, nil }, false
} }
@@ -360,8 +415,9 @@ func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand, localRand *rand.Rand,
items []T, items []T,
lazyRoot func() *template.Template, lazyRoot func() *template.Template,
) ([]keyValueGenerator, bool) { ) ([]keyValueGenerator, bool, bool) {
isDynamic := false isDynamic := false
keysAreStatic := true
generators := make([]keyValueGenerator, len(items)) generators := make([]keyValueGenerator, len(items))
for generatorIndex, item := range items { for generatorIndex, item := range items {
@@ -372,6 +428,7 @@ func buildKeyValueGenerators[T keyValueItem](
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot) keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
if keyIsDynamic { if keyIsDynamic {
isDynamic = true isDynamic = true
keysAreStatic = false
} }
// Generate value functions // Generate value functions
@@ -394,7 +451,7 @@ func buildKeyValueGenerators[T keyValueItem](
} }
} }
return generators, isDynamic return generators, isDynamic, keysAreStatic
} }
func buildStringSliceGenerator( func buildStringSliceGenerator(
+16 -4
View File
@@ -2,7 +2,6 @@ package sarin
import ( import (
"encoding/json" "encoding/json"
"fmt"
"math/big" "math/big"
"os" "os"
"slices" "slices"
@@ -10,14 +9,22 @@ import (
"sync" "sync"
"time" "time"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "charm.land/lipgloss/v2/table"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
const DefaultResponseDurationAccuracy uint32 = 1 const DefaultResponseDurationAccuracy uint32 = 1
const DefaultResponseColumnMaxWidth = 50 const DefaultResponseColumnMaxWidth = 50
// maxResponseKeys bounds the distinct response keys, so error messages carrying
// per-request text cannot grow the map without limit. It stays above the number of
// HTTP status codes, so real ones are never merged.
const maxResponseKeys = 1000
// otherResponseKey collects everything that arrives after the key limit is reached.
const otherResponseKey = "other"
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings. // Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
type Duration time.Duration type Duration time.Duration
@@ -96,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
defer data.Unlock() defer data.Unlock()
response, ok := data.Responses[responseKey] response, ok := data.Responses[responseKey]
if !ok && len(data.Responses) >= maxResponseKeys {
responseKey = otherResponseKey
response, ok = data.Responses[responseKey]
}
if !ok { if !ok {
data.Responses[responseKey] = &Response{ data.Responses[responseKey] = &Response{
durations: map[time.Duration]uint64{ durations: map[time.Duration]uint64{
@@ -159,7 +171,7 @@ func (data *SarinResponseData) PrintTable() {
return cellStyle return cellStyle
}) })
fmt.Println(tbl) lipgloss.Println(tbl)
} }
func (data *SarinResponseData) PrintJSON() { func (data *SarinResponseData) PrintJSON() {
+328 -102
View File
@@ -2,8 +2,13 @@ package sarin
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io"
"log"
"net/url" "net/url"
"os" "os"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -14,20 +19,165 @@ import (
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
) )
type runtimeMessageLevel uint8 type runtimeLogLevel uint8
const ( const (
runtimeMessageLevelWarning runtimeMessageLevel = iota runtimeLogLevelInfo runtimeLogLevel = iota
runtimeMessageLevelError runtimeLogLevelError
) )
type runtimeMessage struct { type runtimeLog struct {
timestamp time.Time timestamp time.Time
level runtimeMessageLevel level runtimeLogLevel
text string text string
} }
type messageSender func(level runtimeMessageLevel, text string) type runtimeLogger func(level runtimeLogLevel, text string)
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
// names are matched case-sensitively, like the other enum options.
func SplitLogLevels(levels string) []string {
var out []string
seen := make(map[string]bool)
for part := range strings.SplitSeq(levels, ",") {
token := strings.TrimSpace(part)
if token == "" || seen[token] {
continue
}
seen[token] = true
out = append(out, token)
}
return out
}
// IsInteractiveTerminal reports whether fd is a terminal that can actually be
// drawn on. A pty reports as a terminal even while its size is still zero, and
// Bubble Tea paints into a width x height cell buffer, so a zero dimension
// renders nothing at all.
func IsInteractiveTerminal(fd uintptr) bool {
if !term.IsTerminal(fd) {
return false
}
width, height, err := term.GetSize(fd)
return err == nil && width > 0 && height > 0
}
// respLogger logs a single completed response.
type respLogger func(duration time.Duration, resp *fasthttp.Response)
func noopLog(runtimeLogLevel, string) {}
func noopRespLog(time.Duration, *fasthttp.Response) {}
// gateSendLog wraps emit with level filtering decided once from the enabled
// levels. It stays general (any level filters correctly) while avoiding a
// per-log check when both levels are on and any work at all when both are off.
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
switch {
case logInfo && logError:
return emit
case logInfo:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelInfo {
emit(level, text)
}
}
case logError:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelError {
emit(level, text)
}
}
default:
return noopLog
}
}
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
// rendering shows.
const respBodySnippetLen = 100
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
// stderr output. ANSI styling is left to the TUI.
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
levelStr := "ERROR"
if level == runtimeLogLevelInfo {
levelStr = "INFO"
}
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
}
// respToLog renders a response as a compact one-line summary for the TUI log box
// (the "[time] INFO:" prefix is added by the TUI).
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
var sb strings.Builder
sb.WriteString(statusCodeToString(resp.StatusCode()))
sb.WriteString(" ")
sb.WriteString(Duration(duration).String())
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
sb.WriteString(" | ")
sb.WriteString(snippet)
}
return sb.String()
}
type respLogEntry struct {
Status int `json:"status"`
Duration string `json:"duration"`
Headers map[string][]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
// respToLogJSON renders a response as a single, self-contained JSON line so the
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
entry := respLogEntry{
Status: resp.StatusCode(),
Duration: Duration(duration).String(),
Headers: collectRespHeaders(resp),
Body: string(resp.Body()),
}
data, err := json.Marshal(entry)
if err != nil {
return `{"error":"failed to marshal response log"}`
}
return string(data)
}
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
headers := make(map[string][]string)
for key, value := range resp.Header.All() {
k := string(key)
headers[k] = append(headers[k], string(value))
}
return headers
}
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
// line (control characters replaced with spaces), with an ellipsis when truncated.
func bodySnippet(body []byte, maxLen int) string {
if len(body) == 0 {
return ""
}
truncated := false
if len(body) > maxLen {
body = body[:maxLen]
truncated = true
}
s := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, string(body))
if truncated {
s += "..."
}
return s
}
type sarin struct { type sarin struct {
workers uint workers uint
@@ -40,16 +190,24 @@ type sarin struct {
totalRequests *uint64 totalRequests *uint64
totalDuration *time.Duration totalDuration *time.Duration
timeout time.Duration timeout time.Duration
quiet bool showProgress bool
skipCertVerify bool skipCertVerify bool
values []string values []string
collectStats bool collectStats bool
dryRun bool dryRun bool
logInfo bool
logError bool
logFile string
hostClients []*fasthttp.HostClient // jobsCtx ends the run, either when the duration expires or on Ctrl+C.
responses *SarinResponseData jobsCtx context.Context //nolint:containedctx
fileCache *FileCache jobsCancel context.CancelFunc
scriptChain *script.Chain
hostClients []*fasthttp.HostClient
scriptHTTPClients []*scriptHTTPClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
} }
// NewSarin creates a new sarin instance for load testing. // NewSarin creates a new sarin instance for load testing.
@@ -65,8 +223,9 @@ func NewSarin(
workers uint, workers uint,
totalRequests *uint64, totalRequests *uint64,
totalDuration *time.Duration, totalDuration *time.Duration,
quiet bool, showProgress bool,
skipCertVerify bool, skipCertVerify bool,
maxResponseBody uint64,
params types.Params, params types.Params,
headers types.Headers, headers types.Headers,
cookies types.Cookies, cookies types.Cookies,
@@ -75,6 +234,8 @@ func NewSarin(
values []string, values []string,
collectStats bool, collectStats bool,
dryRun bool, dryRun bool,
logLevel string,
logFile string,
luaScripts []string, luaScripts []string,
jsScripts []string, jsScripts []string,
) (*sarin, error) { ) (*sarin, error) {
@@ -82,7 +243,23 @@ func NewSarin(
workers = 1 workers = 1
} }
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify) // Resolve which log levels are enabled once, up front.
var logInfo, logError bool
for _, level := range SplitLogLevels(logLevel) {
switch level {
case "info":
logInfo = true
case "error":
logError = true
}
}
proxyURLs := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxyURLs[i] = url.URL(proxy)
}
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -100,25 +277,42 @@ func NewSarin(
scriptChain := script.NewChain(luaSources, jsSources) scriptChain := script.NewChain(luaSources, jsSources)
jobsCtx, jobsCancel := context.WithCancel(ctx)
var scriptHTTPClients []*scriptHTTPClient
if !scriptChain.IsEmpty() {
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
if err != nil {
jobsCancel()
return nil, err
}
}
srn := &sarin{ srn := &sarin{
workers: workers, jobsCtx: jobsCtx,
requestURL: requestURL, jobsCancel: jobsCancel,
methods: methods, workers: workers,
params: params, requestURL: requestURL,
headers: headers, methods: methods,
cookies: cookies, params: params,
bodies: bodies, headers: headers,
totalRequests: totalRequests, cookies: cookies,
totalDuration: totalDuration, bodies: bodies,
timeout: timeout, totalRequests: totalRequests,
quiet: quiet, totalDuration: totalDuration,
skipCertVerify: skipCertVerify, timeout: timeout,
values: values, showProgress: showProgress,
collectStats: collectStats, skipCertVerify: skipCertVerify,
dryRun: dryRun, values: values,
hostClients: hostClients, collectStats: collectStats,
fileCache: NewFileCache(time.Second * 10), dryRun: dryRun,
scriptChain: scriptChain, logInfo: logInfo,
logError: logError,
logFile: logFile,
hostClients: hostClients,
scriptHTTPClients: scriptHTTPClients,
fileCache: NewFileCache(time.Second * 30),
scriptChain: scriptChain,
} }
if collectStats { if collectStats {
@@ -128,73 +322,92 @@ func NewSarin(
return srn, nil return srn, nil
} }
func (q sarin) GetResponses() *SarinResponseData { func (s sarin) GetResponses() *SarinResponseData {
return q.responses return s.responses
} }
func (q sarin) Start(ctx context.Context, stopCtrl *StopController) { func (s sarin) Start(stopCtrl *StopController) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(q.workers, 1)) jobsCh := make(chan struct{}, max(s.workers, 1))
var counter atomic.Uint64 var counter atomic.Uint64
totalRequests := uint64(0) totalRequests := uint64(0)
if q.totalRequests != nil { if s.totalRequests != nil {
totalRequests = *q.totalRequests totalRequests = *s.totalRequests
} }
var streamCtx context.Context onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
var streamCancel context.CancelFunc // The progress bar needs an interactive terminal to render.
var streamCh chan struct{} showProgressBar := s.showProgress && onTerminal
var messageChannel chan runtimeMessage // The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
var sendMessage messageSender // either has something to show: the bar, or logs that would land in the box
// (i.e. logs are enabled and not redirected to a file).
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) { // Open the log file up front, before registering any defer, so a bad path
q.quiet = true // exits cleanly.
var logFile *os.File
if s.logFile != "" {
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
os.Exit(1)
}
defer f.Close() //nolint:errcheck
logFile = f
} }
if q.quiet { var (
sendMessage = func(level runtimeMessageLevel, text string) {} streamCtx context.Context
} else { streamCancel context.CancelFunc
streamCh chan struct{}
tuiLogChannel chan runtimeLog
)
if runTUI {
streamCtx, streamCancel = context.WithCancel(context.Background()) streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel() defer streamCancel()
streamCh = make(chan struct{}) streamCh = make(chan struct{})
messageChannel = make(chan runtimeMessage, max(q.workers, 1)) tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
sendMessage = func(level runtimeMessageLevel, text string) { }
messageChannel <- runtimeMessage{
timestamp: time.Now(), // Route logs: to the file if given, else to the TUI log box while it runs,
level: level, // otherwise to stderr.
text: text, var (
} sendLog runtimeLogger
} sendRespLog respLogger
)
switch {
case logFile != nil:
sendLog, sendRespLog = s.newWriterLog(logFile)
case runTUI:
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
default:
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
} }
// Start workers // Start workers
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage) s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
if !q.quiet { if runTUI {
// Start streaming to terminal go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go q.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, messageChannel)
} }
// Setup duration-based cancellation // Setup duration-based cancellation
q.setupDurationTimeout(ctx, jobsCancel) s.setupDurationTimeout()
// Distribute jobs to workers. // Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled. // This blocks until all jobs are sent or the context is canceled.
q.sendJobs(jobsCtx, jobsCh) s.sendJobs(jobsCh)
// Close the jobs channel so workers stop after completing their current job // Close the jobs channel so workers stop after completing their current job
close(jobsCh) close(jobsCh)
// Wait until all workers stopped // Wait until all workers stopped
workersWG.Wait() workersWG.Wait()
if messageChannel != nil { if tuiLogChannel != nil {
close(messageChannel) close(tuiLogChannel)
} }
if !q.quiet { if runTUI {
// Stop the progress streaming // Stop the progress streaming
streamCancel() streamCancel()
// Wait until progress streaming has completely stopped // Wait until progress streaming has completely stopped
@@ -202,65 +415,78 @@ func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
} }
} }
// newHostClients initializes HTTP clients for the given configuration. // newWriterLog builds the loggers that write formatted lines to w (a log file or
// It can return the following errors: // stderr). sendLog stays general (it filters by each log's level), while sendRespLog
// - types.ProxyDialError // only ever emits info, so its decision is baked once into a no-op when off.
func newHostClients( func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
ctx context.Context, // log.Logger serializes writes with its own mutex, so concurrent workers
timeout time.Duration, // won't interleave lines.
proxies types.Proxies, logger := log.New(w, "", 0)
workers uint,
requestURL *url.URL, sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
skipCertVerify bool, logger.Println(formatRuntimeLogLine(time.Now(), level, text))
) ([]*fasthttp.HostClient, error) { })
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies { var sendRespLog respLogger = noopRespLog
proxiesRaw[i] = url.URL(proxy) if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
}
} }
return NewHostClients( return sendLog, sendRespLog
ctx,
timeout,
proxiesRaw,
workers,
requestURL,
skipCertVerify,
)
} }
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) { // newChannelLog builds the loggers that feed the TUI log box through ch, with the
for range max(q.workers, 1) { // same gating as newWriterLog.
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
})
var sendRespLog respLogger = noopRespLog
if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
}
}
return sendLog, sendRespLog
}
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
for range max(s.workers, 1) {
wg.Go(func() { wg.Go(func() {
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage) s.Worker(jobs, counter, sendLog, sendRespLog)
}) })
} }
} }
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) { func (s sarin) setupDurationTimeout() {
if q.totalDuration != nil { if s.totalDuration != nil {
go func() { go func() {
timer := time.NewTimer(*q.totalDuration) timer := time.NewTimer(*s.totalDuration)
defer timer.Stop() defer timer.Stop()
select { select {
case <-timer.C: case <-timer.C:
cancel() s.jobsCancel()
case <-ctx.Done(): case <-s.jobsCtx.Done():
// Context cancelled, cleanup // Context cancelled, cleanup
} }
}() }()
} }
} }
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) { func (s sarin) sendJobs(jobs chan<- struct{}) {
if q.totalRequests != nil && *q.totalRequests > 0 { if s.totalRequests != nil && *s.totalRequests > 0 {
for range *q.totalRequests { for range *s.totalRequests {
if ctx.Err() != nil { if s.jobsCtx.Err() != nil {
break break
} }
jobs <- struct{}{} jobs <- struct{}{}
} }
} else { } else {
for ctx.Err() == nil { for s.jobsCtx.Err() == nil {
jobs <- struct{}{} jobs <- struct{}{}
} }
} }
+210
View File
@@ -0,0 +1,210 @@
package sarin
import (
"compress/zlib"
"context"
"crypto/tls"
"errors"
"net"
"net/url"
"time"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
)
// scriptHTTPDefaultTimeout is the default timeout for script requests.
const scriptHTTPDefaultTimeout = 10 * time.Second
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
type scriptHTTPClient struct {
ctx context.Context //nolint:containedctx
client *fasthttp.Client
insecureClient *fasthttp.Client
defaultTimeout time.Duration
maxResponseBody int
}
var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
// It can return the following errors:
// - types.ProxyDialError
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) {
if len(proxies) == 0 {
return []*scriptHTTPClient{newScriptHTTPClient(ctx, dialer.DialTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil
}
clients := make([]*scriptHTTPClient, 0, len(proxies))
for _, proxy := range proxies {
dial, err := NewProxyDialFuncWithTimeout(ctx, &proxy)
if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err)
}
clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody))
}
return clients, nil
}
func newScriptHTTPClient(
ctx context.Context,
dial fasthttp.DialFuncWithTimeout,
defaultTimeout time.Duration,
maxConns uint,
maxResponseBody uint64,
) *scriptHTTPClient {
newClient := func(insecure bool) *fasthttp.Client {
// Read and write timeouts stay unset because they would cap the per-call timeout,
// so dialWithDeadline is what bounds the TLS handshake.
return &fasthttp.Client{
DialTimeout: dialWithDeadline(dial),
MaxConnsPerHost: safeUintToInt(maxConns),
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
MaxIdemponentCallAttempts: 1,
ReadBufferSize: 64 << 10, // 64 KiB
TLSConfig: &tls.Config{
InsecureSkipVerify: insecure, //nolint:gosec
},
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
}
}
return &scriptHTTPClient{
ctx: ctx,
client: newClient(false),
insecureClient: newClient(true),
defaultTimeout: defaultTimeout,
maxResponseBody: safeUint64ToInt(maxResponseBody),
}
}
// dialWithDeadline dials within the request timeout and keeps it as the connection deadline,
// so the TLS handshake fasthttp runs on the first write cannot outlive the request.
func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout {
return func(addr string, timeout time.Duration) (net.Conn, error) {
if timeout <= 0 {
timeout = scriptHTTPDefaultTimeout
}
deadline := time.Now().Add(timeout)
conn, err := dial(addr, timeout)
if err != nil {
return nil, err
}
if err := conn.SetDeadline(deadline); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, err
}
return conn, nil
}
}
// Do sends the request. With maxRedirects the timeout applies to each hop.
// It can return the following errors:
// - types.ScriptHTTPRequestError
func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) {
if err := c.ctx.Err(); err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
parsedURL, err := url.Parse(r.URL)
if err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPURLInvalid)
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(r.URL)
req.Header.SetMethod(r.Method)
for key, values := range r.Headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
if len(r.Params) > 0 {
args := req.URI().QueryArgs()
for key, values := range r.Params {
for _, value := range values {
args.Add(key, value)
}
}
}
if len(r.Cookies) > 0 {
req.Header.Add("Cookie", cookieHeaderValue(r.Cookies))
}
req.SetBodyString(r.Body)
timeout := r.Timeout
if timeout <= 0 {
timeout = c.defaultTimeout
}
client := c.client
if r.Insecure {
client = c.insecureClient
}
req.SetTimeout(timeout)
if r.MaxRedirects > 0 {
err = client.DoRedirects(req, resp, r.MaxRedirects)
} else {
err = client.Do(req, resp)
}
if err != nil {
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
err = types.ErrResponseBodyTooLarge
}
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
body, err := decodeResponseBody(resp, c.maxResponseBody)
if err != nil {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
return &script.HTTPResponse{
Status: resp.StatusCode(),
Headers: collectRespHeaders(resp),
Body: string(body),
}, nil
}
// decodeResponseBody decodes a body the server compressed, since scripts read it as text.
// The limit covers the decoded size, which is what a compression bomb inflates to.
// An empty body is left alone because the decoders reject zero bytes, and a HEAD or a 304
// carries the encoding with no body. A coding fasthttp does not decode, and the bare
// deflate stream some servers send without a zlib header, are left raw for the script.
func decodeResponseBody(resp *fasthttp.Response, maxResponseBody int) ([]byte, error) {
if len(resp.Header.ContentEncoding()) == 0 || len(resp.Body()) == 0 {
return resp.Body(), nil
}
decoded, err := resp.BodyUncompressedWithLimit(maxResponseBody)
if errors.Is(err, fasthttp.ErrContentEncodingUnsupported) || errors.Is(err, zlib.ErrHeader) {
return resp.Body(), nil
}
if err != nil {
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
err = types.ErrResponseBodyTooLarge
}
return nil, err
}
// Neither header describes the decoded body, so both go, as net/http does on a gunzip.
resp.Header.SetContentEncoding("")
resp.Header.Del(fasthttp.HeaderContentLength)
return decoded, nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
) )
const forceExitCode = 130 const forceExitCode = 130
+55 -21
View File
@@ -97,7 +97,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
return string(data), nil return string(data), nil
}, },
// json_Object builds a JSON object from interleaved key-value pairs and returns it // json_Object builds a JSON object from interleaved key-value pairs and returns it
// as a JSON string. Keys must be strings; values may be any JSON-encodable type. // as a JSON string. Keys must be strings, values may be any JSON-encodable type.
// Usage: {{ json_Object "name" "Alice" "age" 30 }} // Usage: {{ json_Object "name" "Alice" "age" 30 }}
"json_Object": func(pairs ...any) (string, error) { "json_Object": func(pairs ...any) (string, error) {
if len(pairs)%2 != 0 { if len(pairs)%2 != 0 {
@@ -207,18 +207,20 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Person // Fakeit / Person
// "fakeit_Person": fakeit.Person() *PersonInfo, // "fakeit_Person": fakeit.Person() *PersonInfo,
"fakeit_Name": fakeit.Name, "fakeit_Name": fakeit.Name,
"fakeit_NamePrefix": fakeit.NamePrefix, "fakeit_NamePrefix": fakeit.NamePrefix,
"fakeit_NameSuffix": fakeit.NameSuffix, "fakeit_NameSuffix": fakeit.NameSuffix,
"fakeit_FirstName": fakeit.FirstName, "fakeit_FirstName": fakeit.FirstName,
"fakeit_MiddleName": fakeit.MiddleName, "fakeit_MiddleName": fakeit.MiddleName,
"fakeit_LastName": fakeit.LastName, "fakeit_LastName": fakeit.LastName,
"fakeit_Gender": fakeit.Gender, "fakeit_Gender": fakeit.Gender,
"fakeit_Age": fakeit.Age, "fakeit_Age": fakeit.Age,
"fakeit_Ethnicity": fakeit.Ethnicity, "fakeit_Ethnicity": fakeit.Ethnicity,
"fakeit_SSN": fakeit.SSN, "fakeit_SSN": fakeit.SSN,
"fakeit_EIN": fakeit.EIN, "fakeit_EIN": fakeit.EIN,
"fakeit_Hobby": fakeit.Hobby, "fakeit_Hobby": fakeit.Hobby,
"fakeit_SocialMedia": fakeit.SocialMedia,
"fakeit_Bio": fakeit.Bio,
// "fakeit_Contact": fakeit.Contact() *ContactInfo, // "fakeit_Contact": fakeit.Contact() *ContactInfo,
"fakeit_Email": fakeit.Email, "fakeit_Email": fakeit.Email,
"fakeit_Phone": fakeit.Phone, "fakeit_Phone": fakeit.Phone,
@@ -232,6 +234,9 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// "fakeit_Generate": fakeit.Generate(value string) string, // "fakeit_Generate": fakeit.Generate(value string) string,
"fakeit_Regex": fakeit.Regex, "fakeit_Regex": fakeit.Regex,
// Fakeit / Database
// "fakeit_SQL": fakeit.SQL(so *SQLOptions) (string, error),
// Fakeit / Auth // Fakeit / Auth
"fakeit_Username": fakeit.Username, "fakeit_Username": fakeit.Username,
"fakeit_Password": fakeit.Password, "fakeit_Password": fakeit.Password,
@@ -269,6 +274,15 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
return value return value
}, },
// Fakeit / Airline
"fakeit_AirlineAircraftType": fakeit.AirlineAircraftType,
"fakeit_AirlineAirplane": fakeit.AirlineAirplane,
"fakeit_AirlineAirport": fakeit.AirlineAirport,
"fakeit_AirlineAirportIATA": fakeit.AirlineAirportIATA,
"fakeit_AirlineFlightNumber": fakeit.AirlineFlightNumber,
"fakeit_AirlineRecordLocator": fakeit.AirlineRecordLocator,
"fakeit_AirlineSeat": fakeit.AirlineSeat,
// Fakeit / Game // Fakeit / Game
"fakeit_Gamertag": fakeit.Gamertag, "fakeit_Gamertag": fakeit.Gamertag,
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint, // "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
@@ -302,12 +316,16 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing, "fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
"fakeit_NounCountable": fakeit.NounCountable, "fakeit_NounCountable": fakeit.NounCountable,
"fakeit_NounUncountable": fakeit.NounUncountable, "fakeit_NounUncountable": fakeit.NounUncountable,
"fakeit_NounProper": fakeit.NounProper,
"fakeit_NounDeterminer": fakeit.NounDeterminer,
// Verbs // Verbs
"fakeit_Verb": fakeit.Verb, "fakeit_Verb": fakeit.Verb,
"fakeit_VerbAction": fakeit.VerbAction, "fakeit_VerbAction": fakeit.VerbAction,
"fakeit_VerbLinking": fakeit.VerbLinking, "fakeit_VerbLinking": fakeit.VerbLinking,
"fakeit_VerbHelping": fakeit.VerbHelping, "fakeit_VerbHelping": fakeit.VerbHelping,
"fakeit_VerbTransitive": fakeit.VerbTransitive,
"fakeit_VerbIntransitive": fakeit.VerbIntransitive,
// Adverbs // Adverbs
"fakeit_Adverb": fakeit.Adverb, "fakeit_Adverb": fakeit.Adverb,
@@ -344,6 +362,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative, "fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
"fakeit_PronounInterrogative": fakeit.PronounInterrogative, "fakeit_PronounInterrogative": fakeit.PronounInterrogative,
"fakeit_PronounRelative": fakeit.PronounRelative, "fakeit_PronounRelative": fakeit.PronounRelative,
"fakeit_PronounIndefinite": fakeit.PronounIndefinite,
// Connectives // Connectives
"fakeit_Connective": fakeit.Connective, "fakeit_Connective": fakeit.Connective,
@@ -355,7 +374,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify, "fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
// Words // Words
"fakeit_Word": fakeit.Word, "fakeit_Word": fakeit.Word,
"fakeit_Interjection": fakeit.Interjection,
// Text // Text
"fakeit_Sentence": fakeit.Sentence, "fakeit_Sentence": fakeit.Sentence,
@@ -366,6 +386,11 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Question": fakeit.Question, "fakeit_Question": fakeit.Question,
"fakeit_Quote": fakeit.Quote, "fakeit_Quote": fakeit.Quote,
"fakeit_Phrase": fakeit.Phrase, "fakeit_Phrase": fakeit.Phrase,
"fakeit_PhraseNoun": fakeit.PhraseNoun,
"fakeit_PhraseVerb": fakeit.PhraseVerb,
"fakeit_PhraseAdverb": fakeit.PhraseAdverb,
"fakeit_PhrasePreposition": fakeit.PhrasePreposition,
"fakeit_Comment": fakeit.Comment,
// Fakeit / Foods // Fakeit / Foods
"fakeit_Fruit": fakeit.Fruit, "fakeit_Fruit": fakeit.Fruit,
@@ -375,6 +400,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Dinner": fakeit.Dinner, "fakeit_Dinner": fakeit.Dinner,
"fakeit_Snack": fakeit.Snack, "fakeit_Snack": fakeit.Snack,
"fakeit_Dessert": fakeit.Dessert, "fakeit_Dessert": fakeit.Dessert,
"fakeit_Drink": fakeit.Drink,
// Fakeit / Misc // Fakeit / Misc
"fakeit_Bool": fakeit.Bool, "fakeit_Bool": fakeit.Bool,
@@ -387,6 +413,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Color": fakeit.Color, "fakeit_Color": fakeit.Color,
"fakeit_HexColor": fakeit.HexColor, "fakeit_HexColor": fakeit.HexColor,
"fakeit_RGBColor": fakeit.RGBColor, "fakeit_RGBColor": fakeit.RGBColor,
"fakeit_HSLColor": fakeit.HSLColor,
"fakeit_SafeColor": fakeit.SafeColor, "fakeit_SafeColor": fakeit.SafeColor,
"fakeit_NiceColors": fakeit.NiceColors, "fakeit_NiceColors": fakeit.NiceColors,
@@ -530,6 +557,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Language // Fakeit / Language
"fakeit_Language": fakeit.Language, "fakeit_Language": fakeit.Language,
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation, "fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
"fakeit_LanguageBCP": fakeit.LanguageBCP,
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage, "fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
// Fakeit / Number // Fakeit / Number
@@ -540,25 +568,29 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Int16": fakeit.Int16, "fakeit_Int16": fakeit.Int16,
"fakeit_Int32": fakeit.Int32, "fakeit_Int32": fakeit.Int32,
"fakeit_Int64": fakeit.Int64, "fakeit_Int64": fakeit.Int64,
"fakeit_IntRange": fakeit.IntRange,
"fakeit_Uint": fakeit.Uint, "fakeit_Uint": fakeit.Uint,
"fakeit_UintN": fakeit.UintN, "fakeit_UintN": fakeit.UintN,
"fakeit_Uint8": fakeit.Uint8, "fakeit_Uint8": fakeit.Uint8,
"fakeit_Uint16": fakeit.Uint16, "fakeit_Uint16": fakeit.Uint16,
"fakeit_Uint32": fakeit.Uint32, "fakeit_Uint32": fakeit.Uint32,
"fakeit_Uint64": fakeit.Uint64, "fakeit_Uint64": fakeit.Uint64,
"fakeit_UintRange": fakeit.UintRange,
"fakeit_Float32": fakeit.Float32, "fakeit_Float32": fakeit.Float32,
"fakeit_Float32Range": fakeit.Float32Range, "fakeit_Float32Range": fakeit.Float32Range,
"fakeit_Float64": fakeit.Float64, "fakeit_Float64": fakeit.Float64,
"fakeit_Float64Range": fakeit.Float64Range, "fakeit_Float64Range": fakeit.Float64Range,
// "fakeit_ShuffleInts": fakeit.ShuffleInts, // "fakeit_ShuffleInts": fakeit.ShuffleInts,
"fakeit_RandomInt": fakeit.RandomInt, "fakeit_RandomInt": fakeit.RandomInt,
"fakeit_HexUint": fakeit.HexUint, "fakeit_RandomUint": fakeit.RandomUint,
"fakeit_HexUint": fakeit.HexUint,
// Fakeit / String // Fakeit / String
"fakeit_Digit": fakeit.Digit, "fakeit_Digit": fakeit.Digit,
"fakeit_DigitN": fakeit.DigitN, "fakeit_DigitN": fakeit.DigitN,
"fakeit_Letter": fakeit.Letter, "fakeit_Letter": fakeit.Letter,
"fakeit_LetterN": fakeit.LetterN, "fakeit_LetterN": fakeit.LetterN,
"fakeit_Vowel": fakeit.Vowel,
"fakeit_Lexify": fakeit.Lexify, "fakeit_Lexify": fakeit.Lexify,
"fakeit_Numerify": fakeit.Numerify, "fakeit_Numerify": fakeit.Numerify,
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings, // "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
@@ -602,12 +634,14 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Error // Fakeit / Error
"fakeit_Error": func() string { return fakeit.Error().Error() }, "fakeit_Error": func() string { return fakeit.Error().Error() },
"fakeit_ErrorObject": func() string { return fakeit.ErrorObject().Error() },
"fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() }, "fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() }, "fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() }, "fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() }, "fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() }, "fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
"fakeit_ErrorValidation": func() string { return fakeit.ErrorValidation().Error() },
// Fakeit / School // Fakeit / School
"fakeit_School": fakeit.School, "fakeit_School": fakeit.School,
@@ -717,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
switch { switch {
case strings.HasPrefix(val, "@@"): case strings.HasPrefix(val, "@@"):
// Escaped @ - send as literal string without first @ // Escaped @: send the string without the first @
if err := writer.WriteField(key, val[1:]); err != nil { if err := writer.WriteField(key, val[1:]); err != nil {
return "", err return "", err
} }
+162 -116
View File
@@ -2,38 +2,86 @@ package sarin
import ( import (
"context" "context"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/charmbracelet/bubbles/progress" "charm.land/bubbles/v2/progress"
"github.com/charmbracelet/bubbles/spinner" "charm.land/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
type tickMsg time.Time type tickMsg time.Time
var ( // logBatch carries the log lines accumulated since the last repaint.
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1")) type logBatch []runtimeLog
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true) const (
messageChannelStyle = lipgloss.NewStyle(). // logBoxLines is how many lines the log box shows at once.
Border(lipgloss.ThickBorder(), false, false, false, true). logBoxLines = 8
BorderForeground(lipgloss.Color("#757575")). // logBatchInterval is how often accumulated logs are handed to the program.
PaddingLeft(1). logBatchInterval = 250 * time.Millisecond
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
) )
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true)
errorLabel = errorStyle.Render("ERROR: ")
infoLabel = infoStyle.Render("INFO: ")
logChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
// renderRuntimeLog builds a single styled log line for the TUI log box.
func renderRuntimeLog(log runtimeLog) string {
label := errorLabel
if log.level == runtimeLogLevelInfo {
label = infoLabel
}
return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text
}
// renderLogBox renders the styled log box, or "" when there are no lines.
func renderLogBox(logs []string) string {
var b strings.Builder
for i, line := range logs {
if len(line) > 0 {
b.WriteString(line)
if i < len(logs)-1 {
b.WriteString("\n")
}
}
}
if b.Len() == 0 {
return ""
}
return logChannelStyle.Render(b.String())
}
func helpLine(cancelling bool) string {
if cancelling {
return helpStyle.Render("Stopping... (Ctrl+C again to force)")
}
return helpStyle.Render("Press Ctrl+C to quit")
}
type progressModel struct { type progressModel struct {
progress progress.Model progress progress.Model
startTime time.Time startTime time.Time
messages []string logs []string
counter *atomic.Uint64 counter *atomic.Uint64
current uint64
maxValue uint64 maxValue uint64
showBar bool
ctx context.Context //nolint:containedctx ctx context.Context //nolint:containedctx
stop func() stop func()
cancelling bool cancelling bool
@@ -45,33 +93,24 @@ func (m progressModel) Init() tea.Cmd {
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyPressMsg:
if msg.Type == tea.KeyCtrlC { if msg.String() == "ctrl+c" {
m.cancelling = true m.cancelling = true
m.stop() m.stop()
} }
return m, nil return m, nil
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.progress.Width = max(10, msg.Width-1) m.progress.SetWidth(max(10, msg.Width-1))
if m.ctx.Err() != nil { if m.ctx.Err() != nil {
return m, tea.Quit return m, tea.Quit
} }
return m, nil return m, nil
case runtimeMessage: case logBatch:
var msgBuilder strings.Builder for _, entry := range msg {
msgBuilder.WriteString("[") m.logs = append(m.logs[1:], renderRuntimeLog(entry))
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
} }
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil { if m.ctx.Err() != nil {
return m, tea.Quit return m, tea.Quit
} }
@@ -91,39 +130,28 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
func (m progressModel) View() string { func (m progressModel) View() tea.View {
var messagesBuilder strings.Builder var b strings.Builder
for i, msg := range m.messages { if box := renderLogBox(m.logs); box != "" {
if len(msg) > 0 { b.WriteString(box)
messagesBuilder.WriteString(msg) b.WriteString("\n")
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
} }
var finalBuilder strings.Builder if m.showBar {
if messagesBuilder.Len() > 0 { current := m.counter.Load()
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String())) b.WriteString("\n ")
finalBuilder.WriteString("\n") b.WriteString(strconv.FormatUint(current, 10))
b.WriteString("/")
b.WriteString(strconv.FormatUint(m.maxValue, 10))
b.WriteString(" - ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n ")
b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue)))
} }
m.current = m.counter.Load() b.WriteString("\n\n ")
finalBuilder.WriteString("\n ") b.WriteString(helpLine(m.cancelling))
finalBuilder.WriteString(strconv.FormatUint(m.current, 10)) return tea.NewView(b.String())
finalBuilder.WriteString("/")
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
finalBuilder.WriteString(" - ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
return finalBuilder.String()
} }
func progressTickCmd() tea.Cmd { func progressTickCmd() tea.Cmd {
@@ -138,7 +166,8 @@ type infiniteProgressModel struct {
spinner spinner.Model spinner spinner.Model
startTime time.Time startTime time.Time
counter *atomic.Uint64 counter *atomic.Uint64
messages []string logs []string
showBar bool
ctx context.Context //nolint:containedctx ctx context.Context //nolint:containedctx
quit bool quit bool
stop func() stop func()
@@ -151,26 +180,17 @@ func (m infiniteProgressModel) Init() tea.Cmd {
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyPressMsg:
if msg.Type == tea.KeyCtrlC { if msg.String() == "ctrl+c" {
m.cancelling = true m.cancelling = true
m.stop() m.stop()
} }
return m, nil return m, nil
case runtimeMessage: case logBatch:
var msgBuilder strings.Builder for _, entry := range msg {
msgBuilder.WriteString("[") m.logs = append(m.logs[1:], renderRuntimeLog(entry))
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
} }
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil { if m.ctx.Err() != nil {
m.quit = true m.quit = true
return m, tea.Quit return m, tea.Quit
@@ -188,65 +208,64 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
func (m infiniteProgressModel) View() string { func (m infiniteProgressModel) View() tea.View {
var messagesBuilder strings.Builder var b strings.Builder
for i, msg := range m.messages { if box := renderLogBox(m.logs); box != "" {
if len(msg) > 0 { b.WriteString(box)
messagesBuilder.WriteString(msg) b.WriteString("\n")
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
} }
var finalBuilder strings.Builder // Without a spinner, the view is just the log box (plus help until quit).
if messagesBuilder.Len() > 0 { if !m.showBar {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String())) if !m.quit {
finalBuilder.WriteString("\n") b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
}
return tea.NewView(b.String())
} }
if m.quit { if m.quit {
finalBuilder.WriteString("\n ") b.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10)) b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ") b.WriteString(" ")
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙")) b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
finalBuilder.WriteString(" ") b.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n") b.WriteString("\n\n")
} else { } else {
finalBuilder.WriteString("\n ") b.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10)) b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ") b.WriteString(" ")
finalBuilder.WriteString(m.spinner.View()) b.WriteString(m.spinner.View())
finalBuilder.WriteString(" ") b.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n ") b.WriteString("\n\n ")
if m.cancelling { b.WriteString(helpLine(m.cancelling))
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
} }
return finalBuilder.String() return tea.NewView(b.String())
} }
func (q sarin) streamProgress( func (s sarin) streamProgress(
ctx context.Context, ctx context.Context,
stopCtrl *StopController, stopCtrl *StopController,
done chan<- struct{}, done chan<- struct{},
total uint64, total uint64,
counter *atomic.Uint64, counter *atomic.Uint64,
messageChannel <-chan runtimeMessage, logChannel <-chan runtimeLog,
showBar bool,
) { ) {
var program *tea.Program var program *tea.Program
if total > 0 { if total > 0 {
model := progressModel{ model := progressModel{
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")), progress: progress.New(
progress.WithColors(lipgloss.Color("#151594"), lipgloss.Color("#00D4FF")),
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, progress.DefaultEmptyCharBlock),
),
startTime: time.Now(), startTime: time.Now(),
messages: make([]string, 8), logs: make([]string, logBoxLines),
counter: counter, counter: counter,
current: 0,
maxValue: total, maxValue: total,
showBar: showBar,
ctx: ctx, ctx: ctx,
stop: stopCtrl.Stop, stop: stopCtrl.Stop,
} }
@@ -274,7 +293,8 @@ func (q sarin) streamProgress(
), ),
startTime: time.Now(), startTime: time.Now(),
counter: counter, counter: counter,
messages: make([]string, 8), logs: make([]string, logBoxLines),
showBar: showBar,
ctx: ctx, ctx: ctx,
stop: stopCtrl.Stop, stop: stopCtrl.Stop,
quit: false, quit: false,
@@ -286,9 +306,35 @@ func (q sarin) streamProgress(
stopCtrl.AttachProgram(program) stopCtrl.AttachProgram(program)
defer stopCtrl.AttachProgram(nil) defer stopCtrl.AttachProgram(nil)
// Bubble Tea repaints once per message, so forwarding every log made the run
// wait on the terminal. Send only what the box shows, on a fixed cadence.
go func() { go func() {
for msg := range messageChannel { ticker := time.NewTicker(logBatchInterval)
program.Send(msg) defer ticker.Stop()
pending := make(logBatch, 0, logBoxLines)
flush := func() {
if len(pending) == 0 {
return
}
program.Send(slices.Clone(pending))
pending = pending[:0]
}
for {
select {
case entry, ok := <-logChannel:
if !ok {
flush()
return
}
if len(pending) == logBoxLines {
pending = append(pending[:0], pending[1:]...)
}
pending = append(pending, entry)
case <-ticker.C:
flush()
}
} }
}() }()
+154 -79
View File
@@ -1,39 +1,71 @@
package sarin package sarin
import ( import (
"errors"
"net"
"strconv" "strconv"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
) )
const dryRunResponseKey = "dry-run" const dryRunResponseKey = "dry-run"
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599. // statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
var statusCodeStrings = func() map[int]string { var statusCodeStrings = func() [500]string {
m := make(map[int]string, 500) var codes [500]string
for i := 100; i < 600; i++ { for i := range codes {
m[i] = strconv.Itoa(i) codes[i] = strconv.Itoa(i + 100)
} }
return m return codes
}() }()
// statusCodeToString returns a string representation of the HTTP status code. // statusCodeToString returns a string representation of the HTTP status code.
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others. // Uses a pre-computed table for codes 100-599, falls back to strconv.Itoa for others.
func statusCodeToString(code int) string { func statusCodeToString(code int) string {
if s, ok := statusCodeStrings[code]; ok { if i := code - 100; i >= 0 && i < len(statusCodeStrings) {
return s return statusCodeStrings[i]
} }
return strconv.Itoa(code) return strconv.Itoa(code)
} }
func (q sarin) Worker( // responseErrorKey drops the local address, whose port would mint a stats entry per connection.
func responseErrorKey(err error) string {
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
return types.ErrResponseBodyTooLarge.Error()
}
message := err.Error()
var opErr *net.OpError
if !errors.As(err, &opErr) || opErr.Source == nil {
return message
}
withoutSource := *opErr
withoutSource.Source = nil
return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1)
}
// requestErrorKey keys a generation failure by its source instead of its message.
// A script picks its own URLs and error texts, so the message would mint a new
// stats entry per request. The full message still goes to the runtime log.
func requestErrorKey(err error) string {
if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok {
return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error"
}
return "request generation error"
}
func (s sarin) Worker(
jobs <-chan struct{}, jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
sendRespLog respLogger,
) { ) {
req := fasthttp.AcquireRequest() req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse() resp := fasthttp.AcquireResponse()
@@ -43,143 +75,174 @@ func (q sarin) Worker(
// Create script transformer for this worker (engines are not thread-safe) // Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail // Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer var scriptTransformer *script.Transformer
if !q.scriptChain.IsEmpty() { if !s.scriptChain.IsEmpty() {
var err error var err error
scriptTransformer, err = q.scriptChain.NewTransformer() scriptTransformer, err = s.scriptChain.NewTransformer()
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer scriptTransformer.Close() defer scriptTransformer.Close()
} }
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
requestGenerator, isDynamic := NewRequestGenerator( requestGenerator, isDynamic := NewRequestGenerator(
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer, s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
) )
if q.dryRun { if s.dryRun {
switch { switch {
case q.collectStats && isDynamic: case s.collectStats && isDynamic:
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage) s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
case q.collectStats && !isDynamic: case s.collectStats && !isDynamic:
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage) s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
case !q.collectStats && isDynamic: case !s.collectStats && isDynamic:
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage) s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
default: default:
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage) s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
} }
} else { } else {
switch { switch {
case q.collectStats && isDynamic: case s.collectStats && isDynamic:
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
case q.collectStats && !isDynamic: case s.collectStats && !isDynamic:
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
case !q.collectStats && isDynamic: case !s.collectStats && isDynamic:
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
default: default:
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
} }
} }
} }
func (q sarin) workerStatsWithDynamic( // newScriptProxyBinder returns a func that sends the scripts' http.* calls through proxy i.
func (s sarin) newScriptProxyBinder(scriptTransformer *script.Transformer) func(i int) {
if scriptTransformer == nil || scriptTransformer.IsEmpty() {
return func(int) {}
}
return func(i int) {
scriptTransformer.SetHTTPDoer(s.scriptHTTPClients[i])
}
}
func (s sarin) workerStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
sendRespLog respLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
resp.Reset()
// Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), 0) s.responses.Add(requestErrorKey(err), 0)
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
} }
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout) err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil { if err != nil {
q.responses.Add(err.Error(), time.Since(startTime)) s.responses.Add(responseErrorKey(err), respDuration)
} else { } else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime)) s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
} }
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerStatsWithStatic( func (s sarin) workerStatsWithStatic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
sendRespLog respLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors // Static request generation failed, so record all jobs as errors
errorKey := requestErrorKey(err)
for range jobs { for range jobs {
q.responses.Add(err.Error(), 0) s.responses.Add(errorKey, 0)
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
} }
return return
} }
for range jobs { for range jobs {
resp.Reset()
startTime := time.Now() startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout) err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil { if err != nil {
q.responses.Add(err.Error(), time.Since(startTime)) s.responses.Add(responseErrorKey(err), respDuration)
} else { } else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime)) s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
} }
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerNoStatsWithDynamic( func (s sarin) workerNoStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
sendRespLog respLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
resp.Reset() // Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
} }
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout) startTime := time.Now()
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerNoStatsWithStatic( func (s sarin) workerNoStatsWithStatic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
resp *fasthttp.Response, resp *fasthttp.Response,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator, nextClientIndex clientIndexGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
sendRespLog respLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
// Static request generation failed - just count the jobs without sending // Static request generation failed, so just count the jobs without sending
for range jobs { for range jobs {
counter.Add(1) counter.Add(1)
} }
@@ -187,81 +250,93 @@ func (q sarin) workerNoStatsWithStatic(
} }
for range jobs { for range jobs {
resp.Reset() startTime := time.Now()
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout) err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerDryRunStatsWithDynamic( func (s sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
startTime := time.Now() startTime := time.Now()
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), time.Since(startTime)) s.responses.Add(requestErrorKey(err), time.Since(startTime))
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
continue continue
} }
q.responses.Add(dryRunResponseKey, time.Since(startTime)) s.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerDryRunStatsWithStatic( func (s sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors // Static request generation failed, so record all jobs as errors
errorKey := requestErrorKey(err)
for range jobs { for range jobs {
q.responses.Add(err.Error(), 0) s.responses.Add(errorKey, 0)
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
counter.Add(1) counter.Add(1)
} }
return return
} }
for range jobs { for range jobs {
q.responses.Add(dryRunResponseKey, 0) s.responses.Add(dryRunResponseKey, 0)
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerDryRunNoStatsWithDynamic( func (s sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
) { ) {
for range jobs { for range jobs {
req.Reset() req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
} }
counter.Add(1) counter.Add(1)
} }
} }
func (q sarin) workerDryRunNoStatsWithStatic( func (s sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{}, jobs <-chan struct{},
req *fasthttp.Request, req *fasthttp.Request,
requestGenerator RequestGenerator, requestGenerator RequestGenerator,
counter *atomic.Uint64, counter *atomic.Uint64,
sendMessage messageSender, sendLog runtimeLogger,
) { ) {
if err := requestGenerator(req); err != nil { if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error()) sendLog(runtimeLogLevelError, err.Error())
} }
for range jobs { for range jobs {
+10
View File
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
return nil return nil
} }
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
for _, engine := range t.luaEngines {
engine.SetHTTPDoer(doer)
}
for _, engine := range t.jsEngines {
engine.SetHTTPDoer(doer)
}
}
// Close releases all engine resources. // Close releases all engine resources.
func (t *Transformer) Close() { func (t *Transformer) Close() {
for _, engine := range t.luaEngines { for _, engine := range t.luaEngines {
+133
View File
@@ -0,0 +1,133 @@
package script
import (
"math"
"net/http"
"strconv"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
// HTTPRequest is a request made by a script through the http.* functions.
type HTTPRequest struct {
Method string
URL string
Headers map[string][]string
Params map[string][]string
Cookies map[string][]string
Body string
Timeout time.Duration // zero means the doer's default timeout
Insecure bool
MaxRedirects int
}
// HTTPResponse is the response returned to a script by the http.* functions.
type HTTPResponse struct {
Status int
Headers map[string][]string
Body string
}
// Header returns the first value of the named header, matching the name case-insensitively.
func (r *HTTPResponse) Header(name string) (string, bool) {
for key, values := range r.Headers {
if strings.EqualFold(key, name) && len(values) > 0 {
return values[0], true
}
}
return "", false
}
// Cookie returns the value of the named cookie from the Set-Cookie headers.
func (r *HTTPResponse) Cookie(name string) (string, bool) {
for key, values := range r.Headers {
if !strings.EqualFold(key, "Set-Cookie") {
continue
}
for _, line := range values {
if cookie, err := http.ParseSetCookie(line); err == nil && cookie.Name == name {
return cookie.Value, true
}
}
}
return "", false
}
// HTTPDoer sends the HTTP requests that scripts make.
type HTTPDoer interface {
Do(req *HTTPRequest) (*HTTPResponse, error)
}
// httpDefaultMethod is used when the options set no method.
const httpDefaultMethod = http.MethodGet
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
const httpMaxRedirectsLimit = 100
// Option keys accepted by the http function.
const (
httpOptionMethod = "method"
httpOptionHeaders = "headers"
httpOptionParams = "params"
httpOptionCookies = "cookies"
httpOptionBody = "body"
httpOptionTimeout = "timeout"
httpOptionInsecure = "insecure"
httpOptionMaxRedirects = "maxRedirects"
)
// httpBridge connects http to the doer. Calls only work while transform runs.
type httpBridge struct {
doer HTTPDoer
active bool
}
// It can return the following errors:
// - types.ErrScriptHTTPOutsideTransform
// - types.ErrScriptHTTPUnavailable
// - types.ErrScriptHTTPMethodEmpty
// - any error returned by the doer
func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) {
if !b.active {
return nil, types.ErrScriptHTTPOutsideTransform
}
if b.doer == nil {
return nil, types.ErrScriptHTTPUnavailable
}
if req.Method == "" {
return nil, types.ErrScriptHTTPMethodEmpty
}
return b.doer.Do(req)
}
// parseHTTPMaxRedirects checks that the redirect limit is a non-negative whole number.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPMaxRedirects(count float64) (int, error) {
if math.IsNaN(count) || count < 0 || count > httpMaxRedirectsLimit || count != math.Trunc(count) {
return 0, types.NewScriptHTTPOptionError(
httpOptionMaxRedirects,
types.NewScriptTypeError(
"a whole number between 0 and "+strconv.Itoa(httpMaxRedirectsLimit),
strconv.FormatFloat(count, 'g', -1, 64),
),
)
}
return int(count), nil
}
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPTimeout(value string) (time.Duration, error) {
timeout, err := time.ParseDuration(value)
if err != nil {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, err)
}
if timeout <= 0 {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, types.NewScriptTypeError("a duration greater than 0", value))
}
return timeout, nil
}
+171 -46
View File
@@ -2,6 +2,9 @@ package script
import ( import (
"errors" "errors"
"fmt"
"reflect"
"strconv"
"github.com/dop251/goja" "github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
@@ -11,8 +14,15 @@ import (
type JsEngine struct { type JsEngine struct {
runtime *goja.Runtime runtime *goja.Runtime
transform goja.Callable transform goja.Callable
jsonParse goja.Callable
// errorConstructor is the built-in Error, captured before the script can replace it.
errorConstructor goja.Value
http httpBridge
} }
// promiseType is what a transform returns when it is declared async or as a generator.
var promiseType = reflect.TypeFor[*goja.Promise]()
// NewJsEngine creates a new JavaScript script engine with the given script content. // NewJsEngine creates a new JavaScript script engine with the given script content.
// The script must define a global `transform` function that takes a request object // The script must define a global `transform` function that takes a request object
// and returns the modified request object. // and returns the modified request object.
@@ -29,11 +39,16 @@ type JsEngine struct {
// - types.ScriptExecutionError // - types.ScriptExecutionError
func NewJsEngine(scriptContent string) (*JsEngine, error) { func NewJsEngine(scriptContent string) (*JsEngine, error) {
vm := goja.New() vm := goja.New()
engine := &JsEngine{runtime: vm}
// Register the globals before running the script so its functions can use them
if err := engine.registerHTTP(); err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
}
// Execute the script to define the transform function // Execute the script to define the transform function
_, err := vm.RunString(scriptContent) if _, err := vm.RunString(scriptContent); err != nil {
if err != nil { return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
return nil, types.NewScriptExecutionError("JavaScript", err)
} }
// Get the transform function // Get the transform function
@@ -47,23 +62,43 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function")) return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
} }
return &JsEngine{ // A generator returns an iterator, which is an ordinary object to Go, so the
runtime: vm, // call cannot tell it apart and the declaration is the only thing left to read.
transform: transform, if isGeneratorFunction(vm, transformVal) {
}, nil return nil, types.ErrScriptTransformAsync
}
engine.transform = transform
return engine, nil
}
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
} }
// Transform executes the JavaScript transform function with the given request data. // Transform executes the JavaScript transform function with the given request data.
// It can return the following errors: // It can return the following errors:
// - types.ScriptExecutionError // - types.ScriptExecutionError
func (e *JsEngine) Transform(req *RequestData) error { func (e *JsEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to JavaScript object // Convert RequestData to JavaScript object
reqObj := e.requestDataToObject(req) reqObj := e.requestDataToObject(req)
// Call transform(req) // Call transform(req)
result, err := e.transform(goja.Undefined(), reqObj) result, err := e.transform(goja.Undefined(), reqObj)
if err != nil { if err != nil {
return types.NewScriptExecutionError("JavaScript", err) return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
}
// An async or generator transform returns a promise nothing ever awaits, and a promise
// carries none of the request fields, so the request would go out untransformed.
// ExportType reads the type alone, while Export would run every getter on the result.
if result.ExportType() == promiseType {
return types.ErrScriptTransformAsync
} }
// Update RequestData from the returned object // Update RequestData from the returned object
@@ -79,6 +114,8 @@ func (e *JsEngine) Close() {
// goja doesn't have an explicit close method, but we can help GC // goja doesn't have an explicit close method, but we can help GC
e.runtime = nil e.runtime = nil
e.transform = nil e.transform = nil
e.jsonParse = nil
e.errorConstructor = nil
} }
// requestDataToObject converts RequestData to a goja Value (JavaScript object). // requestDataToObject converts RequestData to a goja Value (JavaScript object).
@@ -124,37 +161,70 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
return types.ErrScriptTransformReturnObject return types.ErrScriptTransformReturnObject
} }
// Method // Reading a field runs the script's own getters and toString, which throw as a Go
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) { // panic outside goja's execution context, so the whole read runs inside Try.
req.Method = v.String() var fieldErr error
if thrown := e.runtime.Try(func() {
// Method
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Method = v.String()
}
// Path
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Path = v.String()
}
// Body
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Body = v.String()
}
// Headers
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
values, err := objectField("headers", v)
if err != nil {
fieldErr = err
return
}
req.Headers = e.objectToStringSliceMap(values)
}
// Params
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
values, err := objectField("params", v)
if err != nil {
fieldErr = err
return
}
req.Params = e.objectToStringSliceMap(values)
}
// Cookies
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
values, err := objectField("cookies", v)
if err != nil {
fieldErr = err
return
}
req.Cookies = e.objectToStringSliceMap(values)
}
}); thrown != nil {
return e.exceptionError(thrown)
} }
// Path return fieldErr
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) { }
req.Path = v.String()
}
// Body // objectField rejects anything that is not a plain object, since reading keys off an
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) { // array or a boxed string yields headers named after indexes and off a Date yields none.
req.Body = v.String() // A Map, a Set and a typed array still pass, so the field is emptied rather than refused.
func objectField(name string, value goja.Value) (*goja.Object, error) {
obj, ok := value.(*goja.Object)
if !ok || obj.ClassName() != "Object" {
return nil, fmt.Errorf("%s must be an object", name)
} }
return obj, nil
// Headers
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Params
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Cookies
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
return nil
} }
// stringSliceToArray converts a Go []string to a JavaScript array. // stringSliceToArray converts a Go []string to a JavaScript array.
@@ -176,23 +246,78 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
result := make(map[string][]string) result := make(map[string][]string)
for _, key := range obj.Keys() { for _, key := range obj.Keys() {
v := obj.Get(key) v := obj.Get(key)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
continue
}
// Check if it's an array // Check if it's an array
if arr, ok := v.Export().([]any); ok { if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
var values []string length := int(arr.Get("length").ToInteger())
for _, item := range arr { values := make([]string, 0, length)
if s, ok := item.(string); ok { for i := range length {
values = append(values, s) if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
values = append(values, text)
} }
} }
result[key] = values result[key] = values
} else { continue
// Single value - wrap in slice }
result[key] = []string{v.String()}
// Single value, wrap it in a slice
if text, ok := jsStringValue(v); ok {
result[key] = []string{text}
} }
} }
return result return result
} }
// exceptionError keeps the thrown value's message without goja's stack frame.
// Stringifying runs through Runtime.Try because a thrown value with no usable
// toString makes goja panic outside its own execution context.
func (e *JsEngine) exceptionError(err error) error {
var exception *goja.Exception
if !errors.As(err, &exception) {
return err
}
var message string
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
message = "unprintable thrown value"
}
return errors.New(message)
}
// jsStringValue renders a JavaScript primitive the way the script would see it.
// Objects, arrays and functions have no useful text form, so they are skipped.
func jsStringValue(v goja.Value) (string, bool) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return "", false
}
if _, isObject := v.(*goja.Object); isObject {
return "", false
}
return v.String(), true
}
// isGeneratorFunction reports whether a function is declared with a star.
// The name comes from the script's own prototype chain, so a script can falsify it either way.
// Reading it can run a script getter, so the whole walk stays inside Try.
func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool {
var name string
thrown := runtime.Try(func() {
obj := value.ToObject(runtime)
if obj == nil {
return
}
constructor := obj.Get("constructor")
if constructor == nil || goja.IsUndefined(constructor) || goja.IsNull(constructor) {
return
}
constructorObj := constructor.ToObject(runtime)
if constructorObj == nil {
return
}
if value := constructorObj.Get("name"); value != nil {
name = value.String()
}
})
return thrown == nil && name == "GeneratorFunction"
}
+220
View File
@@ -0,0 +1,220 @@
package script
import (
"errors"
"github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *JsEngine) registerHTTP() error {
rt := e.runtime
jsonParse, ok := goja.AssertFunction(rt.Get("JSON").ToObject(rt).Get("parse"))
if !ok {
return errors.New("JSON.parse is not available")
}
e.jsonParse = jsonParse
e.errorConstructor = rt.Get("Error")
return rt.Set("http", func(call goja.FunctionCall) goja.Value {
return e.httpCall(call.Argument(0), call.Argument(1))
})
}
func (e *JsEngine) httpCall(urlValue, optsValue goja.Value) goja.Value {
rt := e.runtime
url, ok := urlValue.Export().(string)
if !ok {
panic(rt.NewTypeError("http: url must be a string, got %s", jsTypeName(urlValue)))
}
req := &HTTPRequest{Method: httpDefaultMethod, URL: url}
if !goja.IsUndefined(optsValue) && !goja.IsNull(optsValue) {
opts, ok := optsValue.(*goja.Object)
if !ok {
panic(rt.NewTypeError("http: options must be an object, got %s", jsTypeName(optsValue)))
}
if err := e.parseHTTPOptions(opts, req); err != nil {
e.throw(err)
}
}
resp, err := e.http.do(req)
if err != nil {
e.throw(err)
}
return e.httpResponseToObject(resp)
}
// parseHTTPOptions fills req from a JavaScript options object.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
for _, name := range opts.Keys() {
value := opts.Get(name)
if goja.IsUndefined(value) {
continue
}
switch name {
case httpOptionMethod:
method, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Method = method
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
object, ok := value.(*goja.Object)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("object", jsTypeName(value)))
}
values := e.objectToStringSliceMap(object)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Body = body
case httpOptionTimeout:
timeout, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", jsTypeName(value)))
}
var err error
if req.Timeout, err = parseHTTPTimeout(timeout); err != nil {
return err
}
case httpOptionInsecure:
flag, ok := value.Export().(bool)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value)))
}
req.Insecure = flag
case httpOptionMaxRedirects:
count, ok := numberValue(value)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
}
var err error
if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil {
return err
}
default:
return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
}
return nil
}
// httpResponseToObject converts a response to a JavaScript object.
func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
rt := e.runtime
obj := rt.NewObject()
headers := rt.NewObject()
for key, values := range resp.Headers {
_ = headers.Set(key, e.stringSliceToArray(values))
}
lookup := func(find func(string) (string, bool)) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
name, ok := call.Argument(0).Export().(string)
if !ok {
panic(rt.NewTypeError("name must be a string, got %s", jsTypeName(call.Argument(0))))
}
if value, ok := find(name); ok {
return rt.ToValue(value)
}
return goja.Null()
}
}
_ = obj.Set("status", resp.Status)
_ = obj.Set("headers", headers)
_ = obj.Set("body", resp.Body)
_ = obj.Set("header", lookup(resp.Header))
_ = obj.Set("cookie", lookup(resp.Cookie))
_ = obj.Set("json", func(goja.FunctionCall) goja.Value {
value, err := e.jsonParse(goja.Undefined(), rt.ToValue(resp.Body))
if err != nil {
panic(err)
}
return value
})
// body is a JavaScript string, so bytes that are not valid UTF-8 reach the script
// only through this.
_ = obj.Set("bytes", func(goja.FunctionCall) goja.Value {
buffer := rt.NewArrayBuffer([]byte(resp.Body))
value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer))
if err != nil {
panic(err)
}
return value
})
return obj
}
// jsTypeName returns a short type name for error messages.
func jsTypeName(value goja.Value) string {
switch {
case value == nil || goja.IsUndefined(value):
return "undefined"
case goja.IsNull(value):
return "null"
}
switch value.Export().(type) {
case string:
return "string"
case bool:
return "boolean"
case int64, float64:
return "number"
case []any:
return "array"
default:
if _, ok := goja.AssertFunction(value); ok {
return "function"
}
return "object"
}
}
// throw raises err as a plain JavaScript Error instead of a GoError.
func (e *JsEngine) throw(err error) {
errorValue, newErr := e.runtime.New(e.errorConstructor, e.runtime.ToValue(err.Error()))
if newErr != nil {
panic(e.runtime.NewGoError(err))
}
panic(errorValue)
}
// numberValue reports whether value is a JavaScript number, and returns it.
func numberValue(value goja.Value) (float64, bool) {
switch number := value.Export().(type) {
case int64:
return float64(number), true
case float64:
return number, true
default:
return 0, false
}
}
+53 -44
View File
@@ -11,6 +11,7 @@ import (
type LuaEngine struct { type LuaEngine struct {
state *lua.LState state *lua.LState
transform *lua.LFunction transform *lua.LFunction
http httpBridge
} }
// NewLuaEngine creates a new Lua script engine with the given script content. // NewLuaEngine creates a new Lua script engine with the given script content.
@@ -29,6 +30,11 @@ type LuaEngine struct {
// - types.ScriptExecutionError // - types.ScriptExecutionError
func NewLuaEngine(scriptContent string) (*LuaEngine, error) { func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState() L := lua.NewState()
engine := &LuaEngine{state: L}
// Register the globals before running the script so its functions can use them
engine.registerHTTP()
registerLuaJSON(L)
// Execute the script to define the transform function // Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil { if err := L.DoString(scriptContent); err != nil {
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L.Close() L.Close()
return nil, types.ErrScriptTransformMissing return nil, types.ErrScriptTransformMissing
} }
engine.transform = transform.(*lua.LFunction)
return &LuaEngine{ return engine, nil
state: L, }
transform: transform.(*lua.LFunction),
}, nil // SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
} }
// Transform executes the Lua transform function with the given request data. // Transform executes the Lua transform function with the given request data.
// It can return the following errors: // It can return the following errors:
// - types.ScriptExecutionError // - types.ScriptExecutionError
func (e *LuaEngine) Transform(req *RequestData) error { func (e *LuaEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to Lua table // Convert RequestData to Lua table
reqTable := e.requestDataToTable(req) reqTable := e.requestDataToTable(req)
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
t.RawSetString("path", lua.LString(req.Path)) t.RawSetString("path", lua.LString(req.Path))
t.RawSetString("body", lua.LString(req.Body)) t.RawSetString("body", lua.LString(req.Body))
// Headers (map[string][]string -> table of arrays) t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
headers := L.NewTable() t.RawSetString("params", stringSliceMapToTable(L, req.Params))
for k, values := range req.Headers { t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
arr := L.NewTable()
return t
}
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
t := state.NewTable()
for k, values := range m {
arr := state.NewTable()
for _, v := range values { for _, v := range values {
arr.Append(lua.LString(v)) arr.Append(lua.LString(v))
} }
headers.RawSetString(k, arr) t.RawSetString(k, arr)
} }
t.RawSetString("headers", headers)
// Params (map[string][]string -> table of arrays)
params := L.NewTable()
for k, values := range req.Params {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
params.RawSetString(k, arr)
}
t.RawSetString("params", params)
// Cookies (map[string][]string -> table of arrays)
cookies := L.NewTable()
for k, values := range req.Cookies {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
cookies.RawSetString(k, arr)
}
t.RawSetString("cookies", cookies)
return t return t
} }
@@ -172,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
} }
key := string(k.(lua.LString)) key := string(k.(lua.LString))
switch v.Type() { if v.Type() == lua.LTTable {
case lua.LTString: // Array of values, keys outside 1..n are skipped
// Single string value arr := v.(*lua.LTable)
result[key] = []string{string(v.(lua.LString))} length := arr.Len()
case lua.LTTable: values := make([]string, 0, length)
// Array of strings for i := 1; i <= length; i++ {
var values []string if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
v.(*lua.LTable).ForEach(func(_, item lua.LValue) { values = append(values, text)
if item.Type() == lua.LTString {
values = append(values, string(item.(lua.LString)))
} }
}) }
result[key] = values result[key] = values
return
}
if text, ok := luaScalarString(v); ok {
result[key] = []string{text}
} }
}) })
return result return result
} }
// luaScalarString renders strings, numbers and booleans as text.
func luaScalarString(v lua.LValue) (string, bool) {
switch v.Type() {
case lua.LTString, lua.LTNumber, lua.LTBool:
return v.String(), true
default:
return "", false
}
}
+142
View File
@@ -0,0 +1,142 @@
package script
import (
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *LuaEngine) registerHTTP() {
L := e.state
L.SetGlobal("http", L.NewFunction(func(L *lua.LState) int {
return e.httpCall(L, 1)
}))
}
// httpCall sends the request whose URL is at stack position urlIndex and pushes the response.
func (e *LuaEngine) httpCall(state *lua.LState, urlIndex int) int {
req := &HTTPRequest{
Method: httpDefaultMethod,
URL: state.CheckString(urlIndex),
}
switch opts := state.Get(urlIndex + 1).(type) {
case *lua.LNilType:
case *lua.LTable:
if err := e.parseHTTPOptions(opts, req); err != nil {
state.RaiseError("%s", err.Error())
}
default:
state.ArgError(urlIndex+1, "options must be a table")
}
resp, err := e.http.do(req)
if err != nil {
state.RaiseError("%s", err.Error())
}
state.Push(httpResponseToTable(state, resp))
return 1
}
// parseHTTPOptions fills req from a Lua options table.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
var err error
opts.ForEach(func(k, v lua.LValue) {
if err != nil {
return
}
key, ok := k.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(k.String(), types.ErrScriptHTTPUnknownOption)
return
}
switch name := string(key); name {
case httpOptionMethod:
method, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Method = string(method)
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
table, ok := v.(*lua.LTable)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("table", v.Type().String()))
return
}
values := e.tableToStringSliceMap(table)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Body = string(body)
case httpOptionTimeout:
timeout, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", v.Type().String()))
return
}
req.Timeout, err = parseHTTPTimeout(string(timeout))
case httpOptionInsecure:
flag, ok := v.(lua.LBool)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String()))
return
}
req.Insecure = bool(flag)
case httpOptionMaxRedirects:
count, ok := v.(lua.LNumber)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String()))
return
}
req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count))
default:
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
})
return err
}
// httpResponseToTable converts a response to a Lua table.
func httpResponseToTable(state *lua.LState, resp *HTTPResponse) *lua.LTable {
t := state.NewTable()
t.RawSetString("status", lua.LNumber(resp.Status))
t.RawSetString("headers", stringSliceMapToTable(state, resp.Headers))
t.RawSetString("body", lua.LString(resp.Body))
lookup := func(find func(string) (string, bool)) *lua.LFunction {
return state.NewFunction(func(L *lua.LState) int {
// Last argument, so both res:header() and res.header() work.
top := L.GetTop()
if top == 0 {
L.ArgError(1, "name expected")
}
if value, ok := find(L.CheckString(top)); ok {
L.Push(lua.LString(value))
} else {
L.Push(lua.LNil)
}
return 1
})
}
t.RawSetString("header", lookup(resp.Header))
t.RawSetString("cookie", lookup(resp.Cookie))
return t
}
+159
View File
@@ -0,0 +1,159 @@
package script
import (
"bytes"
"encoding/json"
"math"
"strings"
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerLuaJSON defines the global json table with encode and decode.
func registerLuaJSON(state *lua.LState) {
module := state.NewTable()
module.RawSetString("encode", state.NewFunction(luaJSONEncode))
module.RawSetString("decode", state.NewFunction(luaJSONDecode))
state.SetGlobal("json", module)
}
func luaJSONEncode(state *lua.LState) int {
value, err := luaToGo(state.CheckAny(1), make(map[*lua.LTable]bool))
if err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
state.Push(lua.LString(strings.TrimSuffix(buf.String(), "\n")))
return 1
}
func luaJSONDecode(state *lua.LState) int {
var value any
if err := json.Unmarshal([]byte(state.CheckString(1)), &value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("decode", err).Error())
}
state.Push(goToLua(state, value))
return 1
}
// luaJSONMaxDepth matches the depth encoding/json accepts, so a table a script
// decoded always encodes again, and it stays far below what overflows the Go stack.
const luaJSONMaxDepth = 10000
// luaToGo converts a Lua value to a JSON-encodable Go value.
// It can return the following errors:
// - types.ErrScriptJSONCycle
// - types.ErrScriptJSONDepth
// - types.ScriptTypeError
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
switch v := value.(type) {
case *lua.LNilType:
return nil, nil
case lua.LBool:
return bool(v), nil
case lua.LNumber:
return float64(v), nil
case lua.LString:
return string(v), nil
case *lua.LTable:
if visiting[v] {
return nil, types.ErrScriptJSONCycle
}
// visiting holds the tables on the current path, so its size is the depth.
// A table millions of levels deep overflows the Go stack, which is a fatal
// error no pcall can catch, so the descent is bounded well before that.
if len(visiting) >= luaJSONMaxDepth {
return nil, types.ErrScriptJSONDepth
}
visiting[v] = true
defer delete(visiting, v)
if length, ok := luaArrayLength(v); ok {
array := make([]any, length)
for i := range length {
item, err := luaToGo(v.RawGetInt(i+1), visiting)
if err != nil {
return nil, err
}
array[i] = item
}
return array, nil
}
object := make(map[string]any)
var err error
v.ForEach(func(key, item lua.LValue) {
if err != nil {
return
}
switch key.(type) {
case lua.LString, lua.LNumber:
default:
err = types.NewScriptTypeError("string or number object key", key.Type().String())
return
}
object[key.String()], err = luaToGo(item, visiting)
})
if err != nil {
return nil, err
}
return object, nil
default:
return nil, types.NewScriptTypeError("nil, boolean, number, string or table", value.Type().String())
}
}
// luaArrayLength reports whether t is a non-empty sequence with keys exactly 1..n.
func luaArrayLength(t *lua.LTable) (int, bool) {
count := 0
isArray := true
maxKey := 0.0
t.ForEach(func(key, _ lua.LValue) {
count++
number, ok := key.(lua.LNumber)
if !ok || float64(number) < 1 || float64(number) != math.Trunc(float64(number)) {
isArray = false
return
}
maxKey = max(maxKey, float64(number))
})
if !isArray || count == 0 || maxKey != float64(count) {
return 0, false
}
return count, true
}
// goToLua converts a value produced by encoding/json to a Lua value.
func goToLua(state *lua.LState, value any) lua.LValue {
switch v := value.(type) {
case bool:
return lua.LBool(v)
case float64:
return lua.LNumber(v)
case string:
return lua.LString(v)
case []any:
t := state.CreateTable(len(v), 0)
for i, item := range v {
t.RawSetInt(i+1, goToLua(state, item))
}
return t
case map[string]any:
t := state.CreateTable(0, len(v))
for key, item := range v {
t.RawSetString(key, goToLua(state, item))
}
return t
default:
return lua.LNil
}
}
+15 -4
View File
@@ -31,6 +31,9 @@ type Engine interface {
// The script should modify the RequestData and return it. // The script should modify the RequestData and return it.
Transform(req *RequestData) error Transform(req *RequestData) error
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
SetHTTPDoer(doer HTTPDoer)
// Close releases any resources held by the engine. // Close releases any resources held by the engine.
Close() Close()
} }
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
switch { switch {
case strings.HasPrefix(source, "@@"): case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @ // Escaped @: an inline script starting with a literal @
content = source[1:] // Remove first @, keep the rest content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"): case strings.HasPrefix(source, "@"):
// File or URL reference // File or URL reference
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err return err
} }
// Try to create an engine - this validates syntax and transform function // Creating an engine validates the syntax and the transform function
var engine Engine var engine Engine
switch engineType { switch engineType {
case EngineTypeLua: case EngineTypeLua:
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err return err
} }
// Clean up the engine - we only needed it for validation // The engine was only needed for validation
engine.Close() engine.Close()
return nil return nil
} }
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) // A declared size above the limit is refused before anything is read.
if resp.ContentLength > types.RemoteFetchLimit {
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return "", types.NewHTTPFetchError(url, err) return "", types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return string(data), nil return string(data), nil
} }
+87
View File
@@ -0,0 +1,87 @@
package types
import (
"math"
"strconv"
"strings"
)
// ByteSizeLimit is the largest size any option may ask for. A limit is also the most
// memory one response may take, so it stays well under what a machine can hold.
const ByteSizeLimit = 1 << 30 // 1 GiB
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
const RemoteFetchLimit = 64 << 20 // 64 MiB
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
// 1000 or 1024 bytes.
var byteSizeUnits = []struct {
suffix string
scale uint64
}{
{"TiB", 1 << 40},
{"TB", 1000 * 1000 * 1000 * 1000},
{"GiB", 1 << 30},
{"GB", 1000 * 1000 * 1000},
{"MiB", 1 << 20},
{"MB", 1000 * 1000},
{"KiB", 1 << 10},
{"KB", 1000},
{"B", 1},
}
// ParseByteSize reads a byte count, with or without a unit suffix.
// It can return the following errors:
// - ErrByteSizeInvalid
func ParseByteSize(value string) (uint64, error) {
text := strings.TrimSpace(value)
// The suffix is split off by index, so no case mapping can shift the digits.
digitsEnd := len(text)
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
digitsEnd--
}
scale := uint64(1)
if suffix := text[digitsEnd:]; suffix != "" {
unitScale, ok := byteSizeScale(suffix)
if !ok {
return 0, ErrByteSizeInvalid
}
scale = unitScale
}
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
if err != nil {
return 0, ErrByteSizeInvalid
}
if size > math.MaxUint64/scale {
return 0, ErrByteSizeInvalid
}
return size * scale, nil
}
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
func FormatByteSize(size uint64) string {
for _, unit := range byteSizeUnits {
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
}
}
return strconv.FormatUint(size, 10)
}
// byteSizeScale looks up a unit suffix, ignoring case.
func byteSizeScale(suffix string) (uint64, bool) {
for _, unit := range byteSizeUnits {
if strings.EqualFold(suffix, unit.suffix) {
return unit.scale, true
}
}
return 0, false
}
func isASCIILetter(char byte) bool {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
+116
View File
@@ -10,6 +10,17 @@ import (
var ( var (
errNoError = errors.New("no error (internal)") errNoError = errors.New("no error (internal)")
ErrByteSizeInvalid = errors.New(
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
)
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit")
ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one")
) )
type FieldParseError struct { type FieldParseError struct {
@@ -297,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string {
// ======================================== Config File ======================================== // ======================================== Config File ========================================
// ConfigFileError names the file a parse error came from, which nested configFile chains lose.
type ConfigFileError struct {
Path string
error error
}
func NewConfigFileError(path string, err error) ConfigFileError {
if err == nil {
err = errNoError
}
return ConfigFileError{path, err}
}
func (e ConfigFileError) Error() string {
return e.error.Error()
}
func (e ConfigFileError) Unwrap() error {
return e.error
}
type ConfigFileReadError struct { type ConfigFileReadError struct {
error error error error
} }
@@ -318,6 +350,8 @@ func (e ConfigFileReadError) Unwrap() error {
// ======================================== Proxy ======================================== // ======================================== Proxy ========================================
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
type ProxyUnsupportedSchemeError struct { type ProxyUnsupportedSchemeError struct {
Scheme string Scheme string
} }
@@ -400,9 +434,91 @@ var (
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @") ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function") ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
ErrScriptTransformReturnObject = errors.New("transform function must return an object") ErrScriptTransformReturnObject = errors.New("transform function must return an object")
ErrScriptTransformAsync = errors.New("transform function must be synchronous, async and generator functions are not supported")
ErrScriptURLNoHost = errors.New("script URL must have a host") ErrScriptURLNoHost = errors.New("script URL must have a host")
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
ErrScriptHTTPUnavailable = errors.New("http client is not available")
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
ErrScriptHTTPUnknownOption = errors.New("unknown option")
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
ErrScriptJSONDepth = errors.New("cannot encode a table nested too deeply")
) )
type ScriptTypeError struct {
Expected string
Got string
}
func NewScriptTypeError(expected, got string) ScriptTypeError {
return ScriptTypeError{expected, got}
}
func (e ScriptTypeError) Error() string {
return fmt.Sprintf("expected %s, got %s", e.Expected, e.Got)
}
type ScriptHTTPOptionError struct {
Option string
Err error
}
func NewScriptHTTPOptionError(option string, err error) ScriptHTTPOptionError {
if err == nil {
err = errNoError
}
return ScriptHTTPOptionError{option, err}
}
func (e ScriptHTTPOptionError) Error() string {
return fmt.Sprintf("http option %q: %v", e.Option, e.Err)
}
func (e ScriptHTTPOptionError) Unwrap() error {
return e.Err
}
type ScriptHTTPRequestError struct {
Method string
URL string
Err error
}
func NewScriptHTTPRequestError(method, url string, err error) ScriptHTTPRequestError {
if err == nil {
err = errNoError
}
return ScriptHTTPRequestError{method, url, err}
}
func (e ScriptHTTPRequestError) Error() string {
return fmt.Sprintf("http %s %s: %v", e.Method, e.URL, e.Err)
}
func (e ScriptHTTPRequestError) Unwrap() error {
return e.Err
}
type ScriptJSONError struct {
Op string
Err error
}
func NewScriptJSONError(op string, err error) ScriptJSONError {
if err == nil {
err = errNoError
}
return ScriptJSONError{op, err}
}
func (e ScriptJSONError) Error() string {
return fmt.Sprintf("json.%s: %v", e.Op, e.Err)
}
func (e ScriptJSONError) Unwrap() error {
return e.Err
}
type ScriptLoadError struct { type ScriptLoadError struct {
Source string Source string
Err error Err error
+44
View File
@@ -0,0 +1,44 @@
{
lib,
buildGoModule,
go_1_27,
rev ? "unknown",
buildDate ? "unknown",
}:
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
pname = "sarin";
version = "1.5.0"; # bump per release
src = lib.cleanSource ../.;
vendorHash = "sha256-SpMOMmDa7llS8wbCDf6QJbWtZ/s8RgzMvVP1xrY934s=";
subPackages = [ "cmd/cli" ];
env.CGO_ENABLED = 0; # fully static binary
ldflags = [
"-s"
"-w"
"-X=go.aykhans.me/sarin/internal/version.Version=v${finalAttrs.version}"
"-X=go.aykhans.me/sarin/internal/version.GitCommit=${rev}"
"-X=go.aykhans.me/sarin/internal/version.BuildDate=${buildDate}"
];
preBuild = ''
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
'';
postInstall = ''
mv $out/bin/cli $out/bin/sarin
'';
meta = {
description = "High-performance HTTP load testing tool built with Go and fasthttp";
homepage = "https://github.com/aykhans/sarin";
license = lib.licenses.mit;
mainProgram = "sarin";
maintainers = [ ];
};
})