diff --git a/README.md b/README.md index 8bb9f81..0009b75 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,117 @@ as a plain string because call sites pass constant tokens. Their on the [benchmark charts](https://gofiber.github.io/utils/benchmarks/) and join the catalog above on its next regeneration. +## HTTP dates + +`AppendHTTPDate` and `FormatHTTPDate` write a time in the RFC 9110 +preferred HTTP date format (`Mon, 02 Jan 2006 15:04:05 GMT`, +`net/http.TimeFormat`), byte-identical to `time.Format` with that layout +but without walking a layout string: the fixed-width template is copied +once and only the fields are overwritten. `ParseHTTPDate` is the reverse: +canonical preferred-format input takes a strict scalar fast path, and +everything else — the obsolete RFC 850 and asctime forms, unusual casing, +non-GMT zone names, padding — falls back to `time.Parse` with +`net/http.ParseTime` semantics, including its errors. `Date`, +`Last-Modified`, and `If-Modified-Since` handling sit on every +request/response, which makes these the highest-leverage helpers in this +group for Fiber. + +## URL escaping + +`AppendQueryEscape`/`AppendPathEscape` and +`AppendQueryUnescape`/`AppendPathUnescape` produce byte-identical results +(and, for the unescape pair, identical `url.EscapeError` values) to their +`net/url` counterparts, as append-style, allocation-free single passes. +The escape tables are pinned to `net/url.shouldEscape` by exhaustive +per-byte tests. Unescaping jumps between escape sites with the vectorized +scans (`IndexAny2` when `+` needs rewriting, `bytes.IndexByte` otherwise) +and copies clean spans wholesale, so route parameters and query values +without escapes — the common case — cost one scan and one copy. Decoding +never grows the input, so `dst` may be `s[:0]` on a common backing array +to unescape in place; escaping can grow the input, so there `dst` must +not alias `s`. + +## JSON string escaping + +`AppendJSONString` appends a value as a double-quoted JSON string, +byte-identical to `encoding/json.Marshal` of the same string — including +its default HTML escaping (`<`, `>`, `&`), the `\ufffd` replacement of +invalid UTF-8 bytes, and the U+2028/U+2029 escapes — without any of +`Marshal`'s reflection or allocation. Clean spans are located with a SWAR +scan (the same first-match-mask technique as `IndexNonQuotable`) and +copied wholesale. This is the building block for hand-rolled JSON hot +paths such as access-log lines and error bodies. + +## IP address parsing + +`ParseIPv4` and `ParseIPv6` parse addresses into `netip.Addr`, accepting +exactly the strings `netip.ParseAddr` accepts for the respective family +(pinned by fuzzing) while reporting failure with a `bool` instead of a +constructed error. Both take strings or byte slices, so fasthttp-style +callers skip the `string` conversion entirely; remote-address and +trusted-proxy checks parse an IP on every request in Fiber. + +## Header key canonicalization + +`CanonicalHeaderKey` Title-Cases an HTTP header key exactly like +`net/http.CanonicalHeaderKey`, including its return-unchanged guard for +keys containing non-token bytes. Already-canonical keys — the common case +on receive paths — are validated in a single table pass and returned +as-is with zero allocations for strings and byte slices alike. Keys that +do need rewriting cost one allocation; note that for the ~40 header names +in the stdlib's interning table the stdlib returns a cached string +without allocating, so this helper's edge there is time, not allocations. + +These helpers were added on an amd64 machine, so like the `simd` numbers +their benchmarks are recorded separately from the arm64 catalog above and +join it on its next regeneration: + +Environment: +goos: linux +goarch: amd64 +pkg: github.com/gofiber/utils/v2 +cpu: Intel(R) Xeon(R) Processor @ 2.80GHz + +```text +// go test -benchmem -run=^$ -bench='Benchmark_(Append|Parse)HTTPDate|Benchmark_Append(Query|Path)|Benchmark_AppendJSONString|Benchmark_ParseIPv[46]|Benchmark_CanonicalHeaderKey' -count=1 . +Benchmark_AppendHTTPDate/fiber-4 24732448 50.32 ns/op 0 B/op 0 allocs/op +Benchmark_AppendHTTPDate/default-4 7009405 171.2 ns/op 0 B/op 0 allocs/op +Benchmark_ParseHTTPDate/fiber-4 35094189 32.26 ns/op 0 B/op 0 allocs/op +Benchmark_ParseHTTPDate/default-4 4741988 251.7 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryEscape/clean-64B/fiber-4 18618994 64.21 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryEscape/clean-64B/default-4 4653057 254.4 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryEscape/mixed-64B/fiber-4 4637103 244.2 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryEscape/mixed-64B/default-4 1600225 756.7 ns/op 192 B/op 2 allocs/op +Benchmark_AppendQueryUnescape/plain-64B/fiber-4 56108641 20.74 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryUnescape/plain-64B/default-4 7959500 136.8 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryUnescape/escaped-64B/fiber-4 4737538 250.3 ns/op 0 B/op 0 allocs/op +Benchmark_AppendQueryUnescape/escaped-64B/default-4 3816861 312.7 ns/op 48 B/op 1 allocs/op +Benchmark_AppendPathUnescape/plain-25B/fiber-4 74870614 16.38 ns/op 0 B/op 0 allocs/op +Benchmark_AppendPathUnescape/plain-25B/default-4 11201378 90.13 ns/op 0 B/op 0 allocs/op +Benchmark_AppendPathUnescape/escaped-31B/fiber-4 19748607 62.69 ns/op 0 B/op 0 allocs/op +Benchmark_AppendPathUnescape/escaped-31B/default-4 6227439 173.6 ns/op 24 B/op 1 allocs/op +Benchmark_AppendJSONString/clean-16B/fiber-4 47903053 22.13 ns/op 0 B/op 0 allocs/op +Benchmark_AppendJSONString/clean-16B/default-4 7762933 151.9 ns/op 40 B/op 2 allocs/op +Benchmark_AppendJSONString/clean-64B/fiber-4 18578377 64.12 ns/op 0 B/op 0 allocs/op +Benchmark_AppendJSONString/clean-64B/default-4 3901436 271.8 ns/op 96 B/op 2 allocs/op +Benchmark_AppendJSONString/escaped-64B/fiber-4 7056486 165.8 ns/op 0 B/op 0 allocs/op +Benchmark_AppendJSONString/escaped-64B/default-4 3842581 319.8 ns/op 96 B/op 2 allocs/op +Benchmark_AppendJSONString/unicode-64B/fiber-4 4654760 248.2 ns/op 0 B/op 0 allocs/op +Benchmark_AppendJSONString/unicode-64B/default-4 3786951 309.2 ns/op 112 B/op 2 allocs/op +Benchmark_ParseIPv4/fiber-4 52309202 20.02 ns/op 0 B/op 0 allocs/op +Benchmark_ParseIPv4/default-4 42021915 28.74 ns/op 0 B/op 0 allocs/op +Benchmark_ParseIPv6/compressed/fiber-4 22675394 51.88 ns/op 0 B/op 0 allocs/op +Benchmark_ParseIPv6/compressed/default-4 18522648 64.65 ns/op 0 B/op 0 allocs/op +Benchmark_ParseIPv6/full/fiber-4 20229412 61.40 ns/op 0 B/op 0 allocs/op +Benchmark_ParseIPv6/full/default-4 14830760 83.42 ns/op 0 B/op 0 allocs/op +Benchmark_CanonicalHeaderKey/canonical/fiber-4 54643375 22.69 ns/op 0 B/op 0 allocs/op +Benchmark_CanonicalHeaderKey/canonical/default-4 36882357 28.46 ns/op 0 B/op 0 allocs/op +Benchmark_CanonicalHeaderKey/common-lower/fiber-4 22508378 52.04 ns/op 16 B/op 1 allocs/op +Benchmark_CanonicalHeaderKey/common-lower/default-4 21160863 56.48 ns/op 0 B/op 0 allocs/op +Benchmark_CanonicalHeaderKey/custom-lower/fiber-4 17017753 73.05 ns/op 24 B/op 1 allocs/op +Benchmark_CanonicalHeaderKey/custom-lower/default-4 12678414 102.6 ns/op 24 B/op 1 allocs/op +``` + ## SWAR primitives The [`swar`](swar/) package exports the SWAR (SIMD within a register) @@ -558,114 +669,114 @@ cpu: Intel(R) Xeon(R) Processor @ 2.80GHz (AVX2) ```text // go test ./simd/ -benchmem -run=^$ -bench=Benchmark_ -count=1 -Benchmark_Memchr2/8B/simd-4 160932234 7.510 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/8B/default-4 31407770 39.39 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/32B/simd-4 180379407 7.463 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/32B/default-4 28221582 42.47 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/64B/simd-4 170949894 6.883 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/64B/default-4 21863884 57.19 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/512B/simd-4 59448526 21.04 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/512B/default-4 3130293 391.1 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/4096B/simd-4 10117922 117.0 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr2/4096B/default-4 412353 3055 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/8B/simd-4 141613051 7.994 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/8B/default-4 29630468 40.43 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/32B/simd-4 171311121 6.895 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/32B/default-4 30218668 40.41 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/64B/simd-4 150505892 7.856 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/64B/default-4 19079786 64.43 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/512B/simd-4 52560612 23.74 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/512B/default-4 3086830 388.6 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/4096B/simd-4 7735116 150.1 ns/op 0 B/op 0 allocs/op -Benchmark_Memchr3/4096B/default-4 400952 2975 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/8B/simd-4 89391540 12.82 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/8B/scalar-4 216545959 5.584 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/32B/simd-4 54730342 21.36 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/32B/scalar-4 56326855 21.45 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/64B/simd-4 100000000 10.74 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/64B/scalar-4 27203602 44.19 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/512B/simd-4 55834921 21.79 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/512B/scalar-4 3446038 342.1 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/4096B/simd-4 9817900 122.1 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrPair/4096B/scalar-4 445594 2675 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/8B/simd-4 169659172 7.221 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/8B/scalar-4 234911584 5.033 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/32B/simd-4 174639595 7.164 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/32B/scalar-4 89061945 12.65 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/64B/simd-4 151388893 7.846 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/64B/scalar-4 52860561 23.28 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/512B/simd-4 47377659 25.40 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/512B/scalar-4 7123378 168.5 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/4096B/simd-4 6884252 176.9 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrDigit/4096B/scalar-4 884161 1317 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/8B/simd-4 100000000 10.07 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/8B/scalar-4 134836845 8.672 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/32B/simd-4 149667877 8.193 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/32B/scalar-4 44375330 26.63 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/64B/simd-4 100000000 10.22 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/64B/scalar-4 23879234 50.68 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/512B/simd-4 22524218 46.90 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/512B/scalar-4 2931174 408.6 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/4096B/simd-4 3752906 335.2 ns/op 0 B/op 0 allocs/op -Benchmark_MemchrNotWord/4096B/scalar-4 345715 3136 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/8B/simd-4 100000000 11.61 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/8B/default-4 134337361 9.246 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/32B/simd-4 73584829 15.61 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/32B/default-4 88738117 13.46 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/64B/simd-4 61874611 19.30 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/64B/default-4 74323999 16.76 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/96B/simd-4 22135388 63.57 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/96B/default-4 23687415 50.17 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/128B/simd-4 47676351 24.76 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/128B/default-4 19612291 60.68 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/192B/simd-4 47072698 26.98 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/192B/default-4 11572932 100.1 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/512B/simd-4 34756200 36.05 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/512B/default-4 4604108 261.4 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/4096B/simd-4 8868234 135.8 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem/4096B/default-4 613701 1962 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/8B/paired-4 86125052 14.52 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/8B/paired-stdlib-4 136569471 9.120 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/32B/paired-4 46744827 25.76 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/32B/paired-stdlib-4 95989609 12.81 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/32B/single-4 69678790 17.61 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/32B/single-stdlib-4 68305318 18.38 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/64B/paired-4 64893210 17.20 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/64B/paired-stdlib-4 70806460 16.34 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/64B/single-4 34043565 36.62 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/64B/single-stdlib-4 24664317 47.43 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/96B/paired-4 67596938 19.75 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/96B/paired-stdlib-4 21772609 50.35 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/96B/single-4 25962633 47.83 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/96B/single-stdlib-4 31263175 38.29 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/128B/paired-4 65862615 18.80 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/128B/paired-stdlib-4 18729648 64.33 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/128B/single-4 18243868 65.45 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/128B/single-stdlib-4 23761232 51.21 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/192B/paired-4 60481297 20.08 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/192B/paired-stdlib-4 13275300 93.19 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/192B/single-4 11936652 102.1 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/192B/single-stdlib-4 14467984 83.21 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/512B/paired-4 42353164 28.11 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/512B/paired-stdlib-4 4483054 258.0 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/512B/single-4 4454024 282.8 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/512B/single-stdlib-4 4753104 251.3 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/4096B/paired-4 9504126 124.9 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/4096B/paired-stdlib-4 613930 1985 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/4096B/single-4 563440 1980 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Prefilter/4096B/single-stdlib-4 591222 1924 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Adversarial/simd-4 715 1698023 ns/op 0 B/op 0 allocs/op -Benchmark_Memmem_Adversarial/default-4 722 1722337 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/8B/simd-4 211383338 5.542 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/8B/swar-4 316762524 4.007 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/32B/simd-4 240245251 5.077 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/32B/swar-4 164716737 7.257 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/64B/simd-4 191159047 6.444 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/64B/swar-4 88207845 12.81 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/512B/simd-4 75486206 14.96 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/512B/swar-4 14351298 90.64 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/4096B/simd-4 11831648 101.2 ns/op 0 B/op 0 allocs/op -Benchmark_IsASCII/4096B/swar-4 1512061 685.9 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/8B/simd-4 160932234 7.510 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/8B/default-4 31407770 39.39 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/32B/simd-4 180379407 7.463 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/32B/default-4 28221582 42.47 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/64B/simd-4 170949894 6.883 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/64B/default-4 21863884 57.19 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/512B/simd-4 59448526 21.04 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/512B/default-4 3130293 391.1 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/4096B/simd-4 10117922 117.0 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr2/4096B/default-4 412353 3055 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/8B/simd-4 141613051 7.994 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/8B/default-4 29630468 40.43 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/32B/simd-4 171311121 6.895 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/32B/default-4 30218668 40.41 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/64B/simd-4 150505892 7.856 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/64B/default-4 19079786 64.43 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/512B/simd-4 52560612 23.74 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/512B/default-4 3086830 388.6 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/4096B/simd-4 7735116 150.1 ns/op 0 B/op 0 allocs/op +Benchmark_Memchr3/4096B/default-4 400952 2975 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/8B/simd-4 89391540 12.82 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/8B/scalar-4 216545959 5.584 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/32B/simd-4 54730342 21.36 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/32B/scalar-4 56326855 21.45 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/64B/simd-4 100000000 10.74 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/64B/scalar-4 27203602 44.19 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/512B/simd-4 55834921 21.79 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/512B/scalar-4 3446038 342.1 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/4096B/simd-4 9817900 122.1 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrPair/4096B/scalar-4 445594 2675 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/8B/simd-4 169659172 7.221 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/8B/scalar-4 234911584 5.033 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/32B/simd-4 174639595 7.164 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/32B/scalar-4 89061945 12.65 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/64B/simd-4 151388893 7.846 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/64B/scalar-4 52860561 23.28 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/512B/simd-4 47377659 25.40 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/512B/scalar-4 7123378 168.5 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/4096B/simd-4 6884252 176.9 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrDigit/4096B/scalar-4 884161 1317 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/8B/simd-4 100000000 10.07 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/8B/scalar-4 134836845 8.672 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/32B/simd-4 149667877 8.193 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/32B/scalar-4 44375330 26.63 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/64B/simd-4 100000000 10.22 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/64B/scalar-4 23879234 50.68 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/512B/simd-4 22524218 46.90 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/512B/scalar-4 2931174 408.6 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/4096B/simd-4 3752906 335.2 ns/op 0 B/op 0 allocs/op +Benchmark_MemchrNotWord/4096B/scalar-4 345715 3136 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/8B/simd-4 100000000 11.61 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/8B/default-4 134337361 9.246 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/32B/simd-4 73584829 15.61 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/32B/default-4 88738117 13.46 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/64B/simd-4 61874611 19.30 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/64B/default-4 74323999 16.76 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/96B/simd-4 22135388 63.57 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/96B/default-4 23687415 50.17 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/128B/simd-4 47676351 24.76 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/128B/default-4 19612291 60.68 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/192B/simd-4 47072698 26.98 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/192B/default-4 11572932 100.1 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/512B/simd-4 34756200 36.05 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/512B/default-4 4604108 261.4 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/4096B/simd-4 8868234 135.8 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem/4096B/default-4 613701 1962 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/8B/paired-4 86125052 14.52 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/8B/paired-stdlib-4 136569471 9.120 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/32B/paired-4 46744827 25.76 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/32B/paired-stdlib-4 95989609 12.81 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/32B/single-4 69678790 17.61 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/32B/single-stdlib-4 68305318 18.38 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/64B/paired-4 64893210 17.20 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/64B/paired-stdlib-4 70806460 16.34 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/64B/single-4 34043565 36.62 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/64B/single-stdlib-4 24664317 47.43 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/96B/paired-4 67596938 19.75 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/96B/paired-stdlib-4 21772609 50.35 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/96B/single-4 25962633 47.83 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/96B/single-stdlib-4 31263175 38.29 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/128B/paired-4 65862615 18.80 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/128B/paired-stdlib-4 18729648 64.33 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/128B/single-4 18243868 65.45 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/128B/single-stdlib-4 23761232 51.21 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/192B/paired-4 60481297 20.08 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/192B/paired-stdlib-4 13275300 93.19 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/192B/single-4 11936652 102.1 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/192B/single-stdlib-4 14467984 83.21 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/512B/paired-4 42353164 28.11 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/512B/paired-stdlib-4 4483054 258.0 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/512B/single-4 4454024 282.8 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/512B/single-stdlib-4 4753104 251.3 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/4096B/paired-4 9504126 124.9 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/4096B/paired-stdlib-4 613930 1985 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/4096B/single-4 563440 1980 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Prefilter/4096B/single-stdlib-4 591222 1924 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Adversarial/simd-4 715 1698023 ns/op 0 B/op 0 allocs/op +Benchmark_Memmem_Adversarial/default-4 722 1722337 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/8B/simd-4 211383338 5.542 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/8B/swar-4 316762524 4.007 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/32B/simd-4 240245251 5.077 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/32B/swar-4 164716737 7.257 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/64B/simd-4 191159047 6.444 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/64B/swar-4 88207845 12.81 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/512B/simd-4 75486206 14.96 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/512B/swar-4 14351298 90.64 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/4096B/simd-4 11831648 101.2 ns/op 0 B/op 0 allocs/op +Benchmark_IsASCII/4096B/swar-4 1512061 685.9 ns/op 0 B/op 0 allocs/op ``` ## ☕ Supporters diff --git a/edgecase_test.go b/edgecase_test.go new file mode 100644 index 0000000..fb9ada1 --- /dev/null +++ b/edgecase_test.go @@ -0,0 +1,348 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + "net/netip" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Differential edge-case suites for the helpers that promise byte-for-byte +// stdlib parity. Rather than hand-picking expectations, these enumerate +// structured input spaces — positional mutations, word-boundary offsets, +// malformed UTF-8, calendar boundaries — and require agreement with the +// stdlib reference on every input. + +func Test_ParseHTTPDate_AllMonthsAndWeekdays(t *testing.T) { + t.Parallel() + // Twelve months exercise every httpMonthNum case; seven consecutive + // days exercise every weekday name. + for month := time.January; month <= time.December; month++ { + tm := time.Date(2021, month, 15, 1, 2, 3, 0, time.UTC) + got, err := ParseHTTPDate(FormatHTTPDate(tm)) + require.NoError(t, err, "month %v", month) + require.True(t, got.Equal(tm), "month %v", month) + } + for day := 1; day <= 7; day++ { + tm := time.Date(2021, time.August, day, 23, 59, 59, 0, time.UTC) + got, err := ParseHTTPDate(FormatHTTPDate(tm)) + require.NoError(t, err, "day %d", day) + require.True(t, got.Equal(tm), "day %d", day) + } +} + +func Test_ParseHTTPDate_PositionalMutations(t *testing.T) { + t.Parallel() + base := "Sun, 06 Nov 1994 08:49:37 GMT" + replacements := []byte{' ', ':', ',', '-', '.', '0', '5', '9', 'A', 'M', 'a', 'z', 0x00, 0x7F, 0xFF} + for pos := range len(base) { + for _, r := range replacements { + mutated := base[:pos] + string(r) + base[pos+1:] + assertHTTPDateParity(t, mutated) + } + } + // Length mutations: truncations and extensions. + for cut := range len(base) { + assertHTTPDateParity(t, base[:cut]) + } + assertHTTPDateParity(t, base+" ") + assertHTTPDateParity(t, base+"X") + assertHTTPDateParity(t, " "+base) + assertHTTPDateParity(t, "\t"+base+"\r\n") +} + +func Test_ParseHTTPDate_CalendarBoundaries(t *testing.T) { + t.Parallel() + months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + years := []int{0, 4, 100, 400, 1900, 1996, 2000, 2020, 2023, 9999} + days := []int{0, 1, 28, 29, 30, 31, 32, 99} + for _, year := range years { + for _, month := range months { + for _, day := range days { + in := fmt.Sprintf("Fri, %02d %s %04d 12:00:00 GMT", day, month, year) + assertHTTPDateParity(t, in) + } + } + } + // Clock-field boundaries. + for _, clock := range []string{"00:00:00", "23:59:59", "24:00:00", "23:60:00", "23:59:60", "99:99:99"} { + assertHTTPDateParity(t, "Sat, 15 Jul 2023 "+clock+" GMT") + } +} + +// assertHTTPDateParity requires ParseHTTPDate to agree with the +// net/http.ParseTime reference on outcome, instant, and location. +func assertHTTPDateParity(t *testing.T, in string) { + t.Helper() + want, wantErr := refParseHTTPDate(in) + got, err := ParseHTTPDate(in) + if wantErr != nil { + require.Error(t, err, "input %q", in) + return + } + require.NoError(t, err, "input %q", in) + require.True(t, got.Equal(want), "input %q: got %v, want %v", in, got, want) + require.Equal(t, want.Location().String(), got.Location().String(), "input %q", in) +} + +func Test_AppendUnescape_LongAndBoundaryInputs(t *testing.T) { + t.Parallel() + long := strings.Repeat("abcdefgh", 8) // 64B, engages the vectorized scans + inputs := []string{ + long, + long + "%", + long + "%4", + long + "%41", + long + "%zz", + "%41" + long, + long[:31] + "%" + long[:1], // '%' exactly at index 31 + strings.Repeat("+", 64), + strings.Repeat("%25", 32), + strings.Repeat("a+", 32), + "%", + "+", + "%%%", + "%+1", + "+%41+", + } + for _, in := range inputs { + assertUnescapeParity(t, in) + } + + // A late error must still return the original dst content untouched. + got, err := AppendQueryUnescape([]byte("keep"), long+"%zz") + require.Error(t, err) + require.Equal(t, "keep", string(got)) + gotPath, err := AppendPathUnescape([]byte("keep"), long+"%") + require.Error(t, err) + require.Equal(t, "keep", string(gotPath)) +} + +func Test_AppendUnescape_EveryEscapeByte(t *testing.T) { + t.Parallel() + // Every byte in both positions of an escape pair, plus that byte alone + // after '%', pinned to net/url's accept set and error values. + for i := range 256 { + c := string([]byte{byte(i)}) + assertUnescapeParity(t, "%"+c+"0ab") + assertUnescapeParity(t, "%4"+c+"ab") + assertUnescapeParity(t, "ab%"+c) + } +} + +// assertUnescapeParity requires the two unescape helpers to agree with +// net/url on output and exact error values, for both instantiations. +func assertUnescapeParity(t *testing.T, in string) { + t.Helper() + wantQ, wantQErr := url.QueryUnescape(in) + gotQ, errQ := AppendQueryUnescape(nil, in) + gotQB, errQB := AppendQueryUnescape([]byte(nil), []byte(in)) + wantP, wantPErr := url.PathUnescape(in) + gotP, errP := AppendPathUnescape(nil, in) + if wantQErr != nil { + require.Equal(t, wantQErr, errQ, "query input %q", in) + require.Equal(t, wantQErr, errQB, "query input %q", in) + } else { + require.NoError(t, errQ, "query input %q", in) + require.Equal(t, wantQ, string(gotQ), "query input %q", in) + require.Equal(t, wantQ, string(gotQB), "query input %q", in) + } + if wantPErr != nil { + require.Equal(t, wantPErr, errP, "path input %q", in) + } else { + require.NoError(t, errP, "path input %q", in) + require.Equal(t, wantP, string(gotP), "path input %q", in) + } +} + +func Test_AppendEscape_EdgeCases(t *testing.T) { + t.Parallel() + all := make([]byte, 256) + for i := range all { + all[i] = byte(i) + } + inputs := []string{ + string(all), + strings.Repeat(" ", 64), + strings.Repeat("%", 64), + strings.Repeat("a ", 32), + strings.Repeat("unreserved-only.~_", 16), + "ends with unsafe/", + "/starts with unsafe", + } + for _, in := range inputs { + require.Equal(t, url.QueryEscape(in), string(AppendQueryEscape(nil, in)), "input %q", in) + require.Equal(t, url.PathEscape(in), string(AppendPathEscape(nil, in)), "input %q", in) + } +} + +func Test_AppendJSONString_BoundaryOffsets(t *testing.T) { + t.Parallel() + // Each special byte or sequence at every offset around the SWAR word + // width, so escapes land in every lane and on both sides of the + // word-loop/tail split. + specials := []string{ + "\"", "\\", "\n", "\r", "\t", "\b", "\f", "\x00", "\x1f", "\x7f", + "<", ">", "&", "\xff", "\xc3\xa9", "\u2028", "\u2029", "\xf0\x9f\x98\x80", "\xc3", + } + for _, sp := range specials { + for pad := range 18 { + in := strings.Repeat("a", pad) + sp + strings.Repeat("b", 17-pad) + assertJSONStringParity(t, in) + // And with the special at the very end, where the tail paths run. + assertJSONStringParity(t, strings.Repeat("a", pad)+sp) + } + } +} + +func Test_AppendJSONString_UTF8EdgeCases(t *testing.T) { + t.Parallel() + inputs := []string{ + "\xc0\x80", // overlong NUL + "\xc1\xbf", // overlong + "\xe0\x80\xaf", // overlong 3-byte + "\xed\xa0\x80", // UTF-16 surrogate half + "\xed\xbf\xbf", // UTF-16 surrogate half + "\xf4\x8f\xbf\xbf", // U+10FFFF, highest valid rune + "\xf4\x90\x80\x80", // just above U+10FFFF + "\xf8\x88\x80\x80\x80", // 5-byte form + "\x80", // lone continuation + "\xbf\xbf", // lone continuations + "\xc3", // truncated 2-byte + "\xe2\x80", // truncated 3-byte (prefix of U+2028) + "\xf0\x9f\x98", // truncated 4-byte + "\xef\xbb\xbfBOM", // BOM prefix + "ok\xc3\xa9\xc3", // valid then truncated + "\xe2\x80\xa8\xe2\x80\xa9\xe2\x80\xaa", // U+2028, U+2029, then U+202A (unescaped) + strings.Repeat("\xff", 32), + strings.Repeat("\xc3\xa9", 16), + } + for _, in := range inputs { + assertJSONStringParity(t, in) + } +} + +// assertJSONStringParity requires AppendJSONString to agree with +// encoding/json.Marshal byte for byte, for both instantiations. +func assertJSONStringParity(t *testing.T, in string) { + t.Helper() + want, err := json.Marshal(in) + require.NoError(t, err) + require.Equal(t, string(want), string(AppendJSONString(nil, in)), "input %q", in) + require.Equal(t, string(want), string(AppendJSONString(nil, []byte(in))), "input %q", in) +} + +func Test_ParseIP_PositionalMutations(t *testing.T) { + t.Parallel() + bases := []string{ + "203.0.113.195", + "::1", + "2001:db8::8a2e:370:7334", + "1:2:3:4:5:6:7:8", + "::ffff:1.2.3.4", + "1:2:3:4:5:6:1.2.3.4", + "fe80::1%eth0", + } + replacements := []byte{'.', ':', '%', '0', '9', 'a', 'f', 'g', 'G', '-', ' ', 0x00, 0xFF} + for _, base := range bases { + for pos := range len(base) { + for _, r := range replacements { + assertParseIPParity(t, base[:pos]+string(r)+base[pos+1:]) + } + assertParseIPParity(t, base[:pos]) // every truncation + } + assertParseIPParity(t, base+":") + assertParseIPParity(t, base+".") + assertParseIPParity(t, base+"0") + assertParseIPParity(t, base+"%") + } +} + +func Test_ParseIP_MoreEdgeCases(t *testing.T) { + t.Parallel() + inputs := []string{ + "1;2", // field followed by a non-separator byte + "1:2;3", // same, after a valid group + "fe80::1%%", // zone containing '%' + "1::2%", // empty zone after valid address + "::%25eth0", + "1:2:3:4:5:6:7:8%eth0", // full address with zone + "0.0.0.256", + "0.0.0.00", + "255.255.255.2555", + "::0.0.0.0", + "0::00:0:00:0.0.0.0", + "1:2:3:4:5:1.2.3.4", // v4 tail too early without ellipsis + "1:2:3:4:5:6:7:1.2.3.4", // v4 tail too late + "::ffff:016.1.2.3", + "ABCD:EF01:2345:6789:abcd:ef01:2345:6789", + "0000:0000:0000:0000:0000:0000:0000:0000", + "00000::", + "::00000", + "2:2:2:2:2:2:2:2:", + "\x00::", + "1.2.3.4%eth0", // zones are IPv6-only + } + for _, in := range inputs { + assertParseIPParity(t, in) + } +} + +// assertParseIPParity requires ParseIPv4/ParseIPv6 to accept exactly the +// per-family netip.ParseAddr inputs and produce identical addresses, for +// both instantiations. +func assertParseIPParity(t *testing.T, in string) { + t.Helper() + want, wantErr := netip.ParseAddr(in) + hasColon := strings.ContainsRune(in, ':') + + got4, ok4 := ParseIPv4(in) + _, ok4B := ParseIPv4([]byte(in)) + require.Equal(t, wantErr == nil && !hasColon, ok4, "ParseIPv4 input %q (netip err: %v)", in, wantErr) + require.Equal(t, ok4, ok4B, "ParseIPv4 bytes input %q", in) + if ok4 { + require.Equal(t, want, got4, "ParseIPv4 input %q", in) + } else { + require.Equal(t, netip.Addr{}, got4, "ParseIPv4 input %q", in) + } + + got6, ok6 := ParseIPv6(in) + _, ok6B := ParseIPv6([]byte(in)) + require.Equal(t, wantErr == nil && hasColon, ok6, "ParseIPv6 input %q (netip err: %v)", in, wantErr) + require.Equal(t, ok6, ok6B, "ParseIPv6 bytes input %q", in) + if ok6 { + require.Equal(t, want, got6, "ParseIPv6 input %q", in) + } else { + require.Equal(t, netip.Addr{}, got6, "ParseIPv6 input %q", in) + } +} + +func Test_CanonicalHeaderKey_PositionalMutations(t *testing.T) { + t.Parallel() + bases := []string{"content-type", "X-Request-Id", "a", "-", "--", "a-", "-a", "A--b"} + for _, base := range bases { + for pos := range len(base) { + for c := range 256 { + in := base[:pos] + string(byte(c)) + base[pos+1:] + want := http.CanonicalHeaderKey(in) + require.Equal(t, want, CanonicalHeaderKey(in), "input %q", in) + require.Equal(t, want, string(CanonicalHeaderKey([]byte(in))), "input %q", in) + } + } + } +} + +func Test_CanonicalHeaderKey_InputImmutability(t *testing.T) { + t.Parallel() + // The rewrite path must copy, never mutate the caller's bytes. + in := []byte("content-TYPE") + out := CanonicalHeaderKey(in) + require.Equal(t, "content-TYPE", string(in)) + require.Equal(t, "Content-Type", string(out)) +} diff --git a/fuzz_test.go b/fuzz_test.go index a874edb..e2871b1 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -2,7 +2,13 @@ package utils import ( "bytes" + "encoding/json" + "errors" + "net/http" + "net/netip" + "net/url" "strconv" + "strings" "testing" ) @@ -107,6 +113,176 @@ func FuzzParse(f *testing.F) { }) } +func FuzzURLEscape(f *testing.F) { + f.Add("") + f.Add("a b&c=d/e?f") + f.Add("caf%C3%A9+r%C3%A9sum%C3%A9") + f.Add("trailing%") + f.Add("trailing%2") + f.Add("%zz%1g%%41") + f.Add("100%+tax") + f.Add("\x00\x1f\x7f\xff") + f.Fuzz(func(t *testing.T, s string) { + if want := url.QueryEscape(s); want != string(AppendQueryEscape(nil, s)) || + want != string(AppendQueryEscape(nil, []byte(s))) { + t.Fatalf("AppendQueryEscape(%q) diverges from net/url (%q)", s, want) + } + if want := url.PathEscape(s); want != string(AppendPathEscape(nil, s)) || + want != string(AppendPathEscape(nil, []byte(s))) { + t.Fatalf("AppendPathEscape(%q) diverges from net/url (%q)", s, want) + } + + checkUnescape := func(name, s string, got []byte, err error, want string, wantErr error) { + t.Helper() + if wantErr != nil { + if !errors.Is(err, wantErr) { + t.Fatalf("%s(%q) err = %v, want %v", name, s, err, wantErr) + } + if len(got) != 0 { + t.Fatalf("%s(%q) produced output alongside error", name, s) + } + return + } + if err != nil { + t.Fatalf("%s(%q) err = %v, want nil", name, s, err) + } + if string(got) != want { + t.Fatalf("%s(%q) = %q, want %q", name, s, got, want) + } + } + + wantQ, wantQErr := url.QueryUnescape(s) + gotQ, errQ := AppendQueryUnescape(nil, s) + checkUnescape("AppendQueryUnescape", s, gotQ, errQ, wantQ, wantQErr) + gotQB, errQB := AppendQueryUnescape(nil, []byte(s)) + checkUnescape("AppendQueryUnescape(bytes)", s, gotQB, errQB, wantQ, wantQErr) + + wantP, wantPErr := url.PathUnescape(s) + gotP, errP := AppendPathUnescape(nil, s) + checkUnescape("AppendPathUnescape", s, gotP, errP, wantP, wantPErr) + }) +} + +func FuzzHTTPDate(f *testing.F) { + f.Add("Sun, 06 Nov 1994 08:49:37 GMT") + f.Add("Sunday, 06-Nov-94 08:49:37 GMT") + f.Add("Sun Nov 6 08:49:37 1994") + f.Add(" Tue, 29 Feb 2000 12:00:00 GMT\r\n") + f.Add("Tue, 29 Feb 1900 12:00:00 GMT") + f.Add("sun, 06 nov 1994 08:49:37 GMT") + f.Add("Sun, 06 Nov 1994 08:49:37 UTC") + f.Add("Mon, 01 Jan 0001 00:00:00 GMT") + f.Add("not a date") + f.Fuzz(func(t *testing.T, s string) { + want, wantErr := refParseHTTPDate(s) + got, err := ParseHTTPDate(s) + if (err == nil) != (wantErr == nil) { + t.Fatalf("ParseHTTPDate(%q) err = %v, reference err = %v", s, err, wantErr) + } + if wantErr == nil { + if !got.Equal(want) { + t.Fatalf("ParseHTTPDate(%q) = %v, want %v", s, got, want) + } + if got.Location().String() != want.Location().String() { + t.Fatalf("ParseHTTPDate(%q) location = %v, want %v", s, got.Location(), want.Location()) + } + // Canonical output re-parses through the fast path to the same + // instant, and formatting agrees with the stdlib layout. + if want.Year() >= 0 && want.Year() <= 9999 { + formatted := FormatHTTPDate(want) + if formatted != want.UTC().Format(httpDateLayout) { + t.Fatalf("FormatHTTPDate(%v) = %q diverges from stdlib", want, formatted) + } + round, roundErr := ParseHTTPDate(formatted) + if roundErr != nil || !round.Equal(want) { + t.Fatalf("round trip of %q failed: %v, %v", formatted, round, roundErr) + } + } + } + if gotBytes, errBytes := ParseHTTPDate([]byte(s)); (errBytes == nil) != (err == nil) || (err == nil && !gotBytes.Equal(got)) { + t.Fatalf("ParseHTTPDate(bytes %q) diverges from string form", s) + } + }) +} + +func FuzzAppendJSONString(f *testing.F) { + f.Add("plain") + f.Add("with \"quotes\" and \\slashes") + f.Add("html <&> bits") + f.Add("controls \x00\x1f\x7f") + f.Add("caf\xc3\xa9 \xe2\x80\xa8 \xe2\x80\xa9") + f.Add("broken \xff\xc3( utf8 \xc3") + f.Add("") + f.Fuzz(func(t *testing.T, s string) { + want, err := json.Marshal(s) + if err != nil { + t.Skipf("json.Marshal(%q) failed: %v", s, err) + } + if got := string(AppendJSONString(nil, s)); got != string(want) { + t.Fatalf("AppendJSONString(%q) = %q, want %q", s, got, want) + } + if got := string(AppendJSONString(nil, []byte(s))); got != string(want) { + t.Fatalf("AppendJSONString(bytes %q) = %q, want %q", s, got, want) + } + }) +} + +func FuzzParseIP(f *testing.F) { + f.Add("127.0.0.1") + f.Add("01.2.3.4") + f.Add("255.255.255.256") + f.Add("::ffff:1.2.3.4") + f.Add("1:2:3:4:5:6:7::") + f.Add("1::2::3") + f.Add("fe80::1%eth0") + f.Add("12345::") + f.Add(":::") + f.Add("") + f.Fuzz(func(t *testing.T, s string) { + want, wantErr := netip.ParseAddr(s) + hasColon := strings.ContainsRune(s, ':') + + got4, ok4 := ParseIPv4(s) + if want4 := wantErr == nil && !hasColon; ok4 != want4 { + t.Fatalf("ParseIPv4(%q) ok = %v, want %v (netip err: %v)", s, ok4, want4, wantErr) + } else if want4 && got4 != want { + t.Fatalf("ParseIPv4(%q) = %v, want %v", s, got4, want) + } + if _, okBytes := ParseIPv4([]byte(s)); okBytes != ok4 { + t.Fatalf("ParseIPv4(bytes %q) diverges from string form", s) + } + + got6, ok6 := ParseIPv6(s) + if want6 := wantErr == nil && hasColon; ok6 != want6 { + t.Fatalf("ParseIPv6(%q) ok = %v, want %v (netip err: %v)", s, ok6, want6, wantErr) + } else if want6 && got6 != want { + t.Fatalf("ParseIPv6(%q) = %v, want %v", s, got6, want) + } + if _, okBytes := ParseIPv6([]byte(s)); okBytes != ok6 { + t.Fatalf("ParseIPv6(bytes %q) diverges from string form", s) + } + }) +} + +func FuzzCanonicalHeaderKey(f *testing.F) { + f.Add("content-type") + f.Add("Content-Type") + f.Add("x--double--dash") + f.Add("-leading") + f.Add("bad key") + f.Add("non-ascii-\xc3\xa9") + f.Add("") + f.Fuzz(func(t *testing.T, s string) { + want := http.CanonicalHeaderKey(s) + if got := CanonicalHeaderKey(s); got != want { + t.Fatalf("CanonicalHeaderKey(%q) = %q, want %q", s, got, want) + } + if got := string(CanonicalHeaderKey([]byte(s))); got != want { + t.Fatalf("CanonicalHeaderKey(bytes %q) = %q, want %q", s, got, want) + } + }) +} + func FuzzIndexNonQuotable(f *testing.F) { f.Add("a\tb") f.Add("aaaaaaaa\t\x1f") diff --git a/headerkey.go b/headerkey.go new file mode 100644 index 0000000..971e0d9 --- /dev/null +++ b/headerkey.go @@ -0,0 +1,71 @@ +package utils + +import ( + "github.com/gofiber/utils/v2/internal/caseconv" + "github.com/gofiber/utils/v2/internal/unsafeconv" +) + +// headerTokenTable flags the bytes valid in an HTTP header field name — the +// RFC 9110 token alphabet, matching net/http's validHeaderFieldByte. +var headerTokenTable = buildHeaderTokenTable() + +func buildHeaderTokenTable() [256]bool { + var t [256]bool + for c := byte('0'); c <= '9'; c++ { + t[c] = true + } + for c := byte('a'); c <= 'z'; c++ { + t[c] = true + } + for c := byte('A'); c <= 'Z'; c++ { + t[c] = true + } + for _, c := range []byte("!#$%&'*+-.^_`|~") { + t[c] = true + } + return t +} + +// CanonicalHeaderKey returns the canonical form of the HTTP header key: +// the first letter and any letter following a hyphen upper-cased, the rest +// lower-cased. The output matches net/http.CanonicalHeaderKey byte for +// byte, including its guard: a key containing any byte outside the header +// token alphabet is returned unchanged. Unlike the stdlib it accepts byte +// slices as well as strings and allocates only when the key actually needs +// rewriting — already-canonical keys, the overwhelmingly common case on +// receive paths, are validated in one table pass and returned as-is +// without the stdlib's canonical-key cache lookup. +func CanonicalHeaderKey[S byteSeq](key S) S { + upper := true + fix := -1 + for i := 0; i < len(key); i++ { + c := key[i] + if !headerTokenTable[c] { + return key + } + if fix < 0 && (upper && 'a' <= c && c <= 'z' || !upper && 'A' <= c && c <= 'Z') { + fix = i + } + upper = c == '-' + } + if fix < 0 { + return key + } + + buf := make([]byte, len(key)) + copy(buf, unsafeconv.Bytes(key)) + upper = fix == 0 || buf[fix-1] == '-' + for i := fix; i < len(buf); i++ { + c := buf[i] + if upper { + c = caseconv.ToUpperTable[c] + } else { + c = caseconv.ToLowerTable[c] + } + buf[i] = c + upper = c == '-' + } + // The zero-copy view is sound: buf is uniquely owned here and is never + // written again, which is what the string instantiation requires. + return unsafeconv.Seq[S](buf) +} diff --git a/headerkey_test.go b/headerkey_test.go new file mode 100644 index 0000000..3e92fb8 --- /dev/null +++ b/headerkey_test.go @@ -0,0 +1,77 @@ +package utils + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func headerKeySamples() []string { + return []string{ + "", + "Content-Type", + "content-type", + "CONTENT-TYPE", + "cOnTeNt-TyPe", + "Host", + "host", + "x-request-id", + "X-Request-ID", + "x--double-dash", + "-leading-dash", + "trailing-dash-", + "1-2-3", + "etag", + "WWW-Authenticate", + "www-authenticate", + "Header With Space", + "Bad:Colon", + "Tab\tKey", + "non-ascii-é", + "under_score", + "tilde~ok", + } +} + +func Test_CanonicalHeaderKey(t *testing.T) { + t.Parallel() + for _, in := range headerKeySamples() { + want := http.CanonicalHeaderKey(in) + require.Equal(t, want, CanonicalHeaderKey(in), "input %q", in) + require.Equal(t, want, string(CanonicalHeaderKey([]byte(in))), "input %q", in) + } + + // Already-canonical and invalid keys come back without copying: for + // []byte inputs the same backing array is returned. + for _, in := range []string{"Content-Type", "Bad Key", "X-Id"} { + b := []byte(in) + out := CanonicalHeaderKey(b) + require.Same(t, &b[0], &out[0], "input %q must not copy", in) + } +} + +func Benchmark_CanonicalHeaderKey(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"canonical", "Content-Type"}, + {"common-lower", "content-type"}, + {"custom-lower", "x-custom-request-id"}, + } + for _, input := range inputs { + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + CanonicalHeaderKey(input.value) + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + http.CanonicalHeaderKey(input.value) + } + }) + } +} diff --git a/httpdate.go b/httpdate.go new file mode 100644 index 0000000..edbda9c --- /dev/null +++ b/httpdate.go @@ -0,0 +1,221 @@ +package utils + +import ( + "time" +) + +// httpDateLayout is the RFC 9110 preferred date format (net/http.TimeFormat), +// a fixed-width 29-byte layout that doubles as the formatting template: every +// separator byte is already in place, so AppendHTTPDate only overwrites the +// fields. +const ( + httpDateLayout = "Mon, 02 Jan 2006 15:04:05 GMT" + httpDateLen = len(httpDateLayout) +) + +var ( + httpWeekdays = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + httpMonths = [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + + // httpDaysInMonth is indexed by time.Month; February is adjusted for + // leap years in daysInHTTPMonth. + httpDaysInMonth = [13]int{0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} +) + +// AppendHTTPDate appends t in the RFC 9110 preferred HTTP date format +// ("Mon, 02 Jan 2006 15:04:05 GMT", net/http.TimeFormat) to dst and returns +// the extended slice. The output is byte-identical to +// t.UTC().AppendFormat(dst, http.TimeFormat) and always 29 bytes for the +// years 0..9999 that HTTP dates can represent; times outside that range +// delegate to time.AppendFormat. +func AppendHTTPDate(dst []byte, t time.Time) []byte { + t = t.UTC() + year, month, day := t.Date() + if year < 0 || year > 9999 { + // RFC 1123 assumes a four-digit year; keep stdlib behavior for the + // unrepresentable rest instead of mis-padding it. + return t.AppendFormat(dst, httpDateLayout) + } + hour, minute, sec := t.Clock() + off := len(dst) + dst = append(dst, httpDateLayout...) + b := dst[off : off+httpDateLen : off+httpDateLen] + copy(b, httpWeekdays[t.Weekday()]) + b[5] = byte(day/10) + '0' + b[6] = byte(day%10) + '0' + copy(b[8:11], httpMonths[month-1]) + b[12] = byte(year/1000) + '0' + b[13] = byte(year/100%10) + '0' + b[14] = byte(year/10%10) + '0' + b[15] = byte(year%10) + '0' + b[17] = byte(hour/10) + '0' + b[18] = byte(hour%10) + '0' + b[20] = byte(minute/10) + '0' + b[21] = byte(minute%10) + '0' + b[23] = byte(sec/10) + '0' + b[24] = byte(sec%10) + '0' + return dst +} + +// FormatHTTPDate returns t in the RFC 9110 preferred HTTP date format, equal +// to t.UTC().Format(http.TimeFormat) with a single allocation for the result. +func FormatHTTPDate(t time.Time) string { + var buf [httpDateLen]byte + return string(AppendHTTPDate(buf[:0], t)) +} + +// ParseHTTPDate parses an HTTP date the way net/http.ParseTime does: +// the RFC 9110 preferred format ("Mon, 02 Jan 2006 15:04:05 GMT") plus the +// obsolete RFC 850 and ANSI C asctime forms, with surrounding ASCII +// whitespace tolerated. Canonical preferred-format input takes a fast scalar +// path that never calls time.Parse; every other input — legacy formats, +// unusual casing, non-GMT zone names, padding — falls back to time.Parse +// with byte-for-byte stdlib semantics, including its errors. The returned +// time is in time.UTC on the fast path and whatever time.Parse yields on the +// fallback; the instants agree in both cases. +func ParseHTTPDate[S byteSeq](s S) (time.Time, error) { + if t, ok := parseRFC1123(s); ok { + return t, nil + } + return parseHTTPDateSlow(string(s)) +} + +// parseRFC1123 is the strict fast path for canonical +// "Mon, 02 Jan 2006 15:04:05 GMT" input: exact length, exact separators, +// exact-case names. It reports ok=false for anything else — including +// out-of-range fields — so the slow path can produce stdlib-identical +// results and errors for the long tail. +func parseRFC1123[S byteSeq](s S) (time.Time, bool) { + if len(s) != httpDateLen { + return time.Time{}, false + } + if s[3] != ',' || s[4] != ' ' || s[7] != ' ' || s[11] != ' ' || s[16] != ' ' || + s[19] != ':' || s[22] != ':' || s[25] != ' ' || + s[26] != 'G' || s[27] != 'M' || s[28] != 'T' { + return time.Time{}, false + } + if !isHTTPWeekday(pack3(s[0], s[1], s[2])) { + return time.Time{}, false + } + month := httpMonthNum(pack3(s[8], s[9], s[10])) + if month == 0 { + return time.Time{}, false + } + day, okDay := twoDigits(s[5], s[6]) + yh, okYh := twoDigits(s[12], s[13]) + yl, okYl := twoDigits(s[14], s[15]) + hour, okHour := twoDigits(s[17], s[18]) + minute, okMin := twoDigits(s[20], s[21]) + sec, okSec := twoDigits(s[23], s[24]) + if !okDay || !okYh || !okYl || !okHour || !okMin || !okSec { + return time.Time{}, false + } + year := yh*100 + yl + if hour > 23 || minute > 59 || sec > 59 || + day < 1 || day > daysInHTTPMonth(month, year) { + // time.Parse rejects these; let it produce the exact error. + return time.Time{}, false + } + return time.Date(year, time.Month(month), day, hour, minute, sec, 0, time.UTC), true +} + +// parseHTTPDateSlow mirrors net/http.ParseTime: trim the whitespace +// textproto would, then try the three allowed layouts in order, returning +// the last error when none matches. +func parseHTTPDateSlow(s string) (time.Time, error) { + start, end := 0, len(s) + for start < end && isHTTPDateSpace(s[start]) { + start++ + } + for start < end && isHTTPDateSpace(s[end-1]) { + end-- + } + s = s[start:end] + t, err := time.Parse(httpDateLayout, s) + if err == nil { + return t, nil + } + if t, err850 := time.Parse(time.RFC850, s); err850 == nil { + return t, nil + } + t, errANSIC := time.Parse(time.ANSIC, s) + if errANSIC == nil { + return t, nil + } + return time.Time{}, errANSIC +} + +// isHTTPDateSpace matches textproto.TrimString's notion of whitespace. +func isHTTPDateSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' +} + +// pack3 packs three bytes for the name switches below. +func pack3(a, b, c byte) uint32 { + return uint32(a)<<16 | uint32(b)<<8 | uint32(c) +} + +// isHTTPWeekday reports whether w is one of the seven exact-case short +// weekday names. Like time.Parse, callers do not check the name against the +// date; any valid name is accepted. +func isHTTPWeekday(w uint32) bool { + switch w { + case 'S'<<16 | 'u'<<8 | 'n', 'M'<<16 | 'o'<<8 | 'n', 'T'<<16 | 'u'<<8 | 'e', + 'W'<<16 | 'e'<<8 | 'd', 'T'<<16 | 'h'<<8 | 'u', 'F'<<16 | 'r'<<8 | 'i', + 'S'<<16 | 'a'<<8 | 't': + return true + } + return false +} + +// httpMonthNum maps an exact-case short month name to 1..12, or 0 if w is +// not one. +func httpMonthNum(w uint32) int { + switch w { + case 'J'<<16 | 'a'<<8 | 'n': + return 1 + case 'F'<<16 | 'e'<<8 | 'b': + return 2 + case 'M'<<16 | 'a'<<8 | 'r': + return 3 + case 'A'<<16 | 'p'<<8 | 'r': + return 4 + case 'M'<<16 | 'a'<<8 | 'y': + return 5 + case 'J'<<16 | 'u'<<8 | 'n': + return 6 + case 'J'<<16 | 'u'<<8 | 'l': + return 7 + case 'A'<<16 | 'u'<<8 | 'g': + return 8 + case 'S'<<16 | 'e'<<8 | 'p': + return 9 + case 'O'<<16 | 'c'<<8 | 't': + return 10 + case 'N'<<16 | 'o'<<8 | 'v': + return 11 + case 'D'<<16 | 'e'<<8 | 'c': + return 12 + } + return 0 +} + +// twoDigits parses two ASCII digits into an int, reporting whether both +// bytes were digits. +func twoDigits(a, b byte) (int, bool) { + a -= '0' + b -= '0' + if a > 9 || b > 9 { + return 0, false + } + return int(a)*10 + int(b), true +} + +// daysInHTTPMonth returns the number of days in month for year, using the +// same leap-year rule as the time package. +func daysInHTTPMonth(month, year int) int { + if month == 2 && year%4 == 0 && (year%100 != 0 || year%400 == 0) { + return 29 + } + return httpDaysInMonth[month] +} diff --git a/httpdate_test.go b/httpdate_test.go new file mode 100644 index 0000000..fd6a1b9 --- /dev/null +++ b/httpdate_test.go @@ -0,0 +1,161 @@ +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// refParseHTTPDate mirrors net/http.ParseTime: trim the whitespace textproto +// would, then try the three allowed layouts in order. It is the behavioral +// reference for ParseHTTPDate in the unit and fuzz tests. +func refParseHTTPDate(s string) (time.Time, error) { + start, end := 0, len(s) + for start < end && isHTTPDateSpace(s[start]) { + start++ + } + for start < end && isHTTPDateSpace(s[end-1]) { + end-- + } + s = s[start:end] + var t time.Time + var err error + for _, layout := range []string{httpDateLayout, time.RFC850, time.ANSIC} { + t, err = time.Parse(layout, s) + if err == nil { + return t, nil + } + } + return t, err +} + +func httpDateSamples() []time.Time { + return []time.Time{ + {}, // zero time: Mon, 01 Jan 0001 + time.Unix(0, 0), + time.Unix(784111777, 0), // the RFC 9110 example date + time.Date(2000, time.February, 29, 23, 59, 59, 0, time.UTC), // leap day + time.Date(1900, time.February, 28, 0, 0, 0, 0, time.UTC), // century non-leap + time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC), // last 4-digit second + time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC), // year 0000 + time.Date(2026, time.July, 23, 12, 34, 56, 789, time.UTC), // sub-second truncated by format + time.Date(2026, time.July, 23, 20, 0, 0, 0, time.FixedZone("PST", -8*3600)), // non-UTC input + } +} + +func Test_AppendHTTPDate(t *testing.T) { + t.Parallel() + for _, tm := range httpDateSamples() { + want := tm.UTC().Format(httpDateLayout) + require.Equal(t, want, string(AppendHTTPDate(nil, tm))) + require.Equal(t, want, FormatHTTPDate(tm)) + require.Len(t, want, httpDateLen) + } + + // Out-of-range years delegate to time.AppendFormat. + for _, tm := range []time.Time{ + time.Date(10000, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(-42, time.January, 1, 0, 0, 0, 0, time.UTC), + } { + want := tm.UTC().Format(httpDateLayout) + require.Equal(t, want, string(AppendHTTPDate(nil, tm))) + } + + // Appending must preserve existing dst content. + got := AppendHTTPDate([]byte("Date: "), time.Unix(0, 0)) + require.Equal(t, "Date: Thu, 01 Jan 1970 00:00:00 GMT", string(got)) +} + +func Test_ParseHTTPDate(t *testing.T) { + t.Parallel() + + // Canonical fast-path inputs round-trip through the formatter. + for _, tm := range httpDateSamples() { + s := FormatHTTPDate(tm) + got, err := ParseHTTPDate(s) + require.NoError(t, err, "input %q", s) + require.True(t, got.Equal(tm.Truncate(time.Second)), "input %q: got %v", s, got) + require.Same(t, time.UTC, got.Location(), "input %q", s) + + gotBytes, err := ParseHTTPDate([]byte(s)) + require.NoError(t, err) + require.True(t, gotBytes.Equal(got)) + } + + // Every input, valid or not, must agree with the net/http.ParseTime + // reference on outcome, instant, and location. + inputs := []string{ + "Sun, 06 Nov 1994 08:49:37 GMT", + "Sunday, 06-Nov-94 08:49:37 GMT", // RFC 850 + "Sun Nov 6 08:49:37 1994", // ANSI C asctime + " Sun, 06 Nov 1994 08:49:37 GMT\r\n", // padded, per textproto trim + "sun, 06 nov 1994 08:49:37 GMT", // lowercase names: stdlib folds case + "Sun, 06 Nov 1994 08:49:37 UTC", // non-GMT zone literal + "Mon, 06 Nov 1994 08:49:37 GMT", // wrong weekday for the date: accepted + "Tue, 29 Feb 2000 12:00:00 GMT", // leap day + "Tue, 29 Feb 1900 12:00:00 GMT", // century non-leap: rejected + "Tue, 30 Feb 2020 12:00:00 GMT", + "Tue, 00 Feb 2020 12:00:00 GMT", + "Tue, 32 Jan 2020 12:00:00 GMT", + "Tue, 15 Jan 2020 24:00:00 GMT", + "Tue, 15 Jan 2020 12:60:00 GMT", + "Tue, 15 Jan 2020 12:00:60 GMT", + "Foo, 15 Jan 2020 12:00:00 GMT", + "Tue, 15 Foo 2020 12:00:00 GMT", + "Tue, 15 Jan 2020 12:00:00 GM", + "Tue, 15 Jan 2020 12:00:00", + "", + "not a date", + "Tue, 1x Jan 2020 12:00:00 GMT", + "Tue, 15 Jan 2x20 12:00:00 GMT", + } + for _, in := range inputs { + want, wantErr := refParseHTTPDate(in) + got, err := ParseHTTPDate(in) + if wantErr != nil { + require.Error(t, err, "input %q", in) + continue + } + require.NoError(t, err, "input %q", in) + require.True(t, got.Equal(want), "input %q: got %v, want %v", in, got, want) + require.Equal(t, want.Location().String(), got.Location().String(), "input %q", in) + } +} + +func Benchmark_AppendHTTPDate(b *testing.B) { + tm := time.Unix(784111777, 0) + dst := make([]byte, 0, httpDateLen) + b.Run("fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + AppendHTTPDate(dst, tm) + } + }) + b.Run("default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + tm.UTC().AppendFormat(dst, httpDateLayout) + } + }) +} + +func Benchmark_ParseHTTPDate(b *testing.B) { + input := "Sun, 06 Nov 1994 08:49:37 GMT" + b.Run("fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := ParseHTTPDate(input); err != nil { + b.Fatal(err) + } + } + }) + b.Run("default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := time.Parse(httpDateLayout, input); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/internal/unsafeconv/unsafeconv.go b/internal/unsafeconv/unsafeconv.go index a179988..0927938 100644 --- a/internal/unsafeconv/unsafeconv.go +++ b/internal/unsafeconv/unsafeconv.go @@ -23,3 +23,15 @@ func Bytes[S ~string | ~[]byte](s S) []byte { // #nosec G103 return unsafe.Slice(*(**byte)(unsafe.Pointer(&s)), len(s)) } + +// Seq is Bytes' inverse: it returns b viewed as S without allocation, for +// either instantiation. It relies on the string header being a prefix of +// the slice header (data pointer, then length), so reinterpreting a slice +// header yields a valid string view; the []byte instantiation reads the +// full header back unchanged. The caller must guarantee b is never written +// again when S's underlying type is string, since the result would alias +// memory the runtime assumes immutable. +func Seq[S ~string | ~[]byte](b []byte) S { + // #nosec G103 + return *(*S)(unsafe.Pointer(&b)) +} diff --git a/internal/unsafeconv/unsafeconv_test.go b/internal/unsafeconv/unsafeconv_test.go new file mode 100644 index 0000000..249ffa1 --- /dev/null +++ b/internal/unsafeconv/unsafeconv_test.go @@ -0,0 +1,60 @@ +package unsafeconv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type ( + myString string + myBytes []byte +) + +func Test_UnsafeString(t *testing.T) { + t.Parallel() + b := []byte("hello world") + s := UnsafeString(b) + require.Equal(t, "hello world", s) + require.Empty(t, UnsafeString(nil)) + require.Empty(t, UnsafeString([]byte{})) +} + +func Test_UnsafeBytes(t *testing.T) { + t.Parallel() + require.Equal(t, []byte("hello world"), UnsafeBytes("hello world")) + require.Empty(t, UnsafeBytes("")) +} + +func Test_Bytes(t *testing.T) { + t.Parallel() + require.Equal(t, []byte("from string"), Bytes("from string")) + require.Equal(t, []byte("from bytes"), Bytes([]byte("from bytes"))) + require.Equal(t, []byte("named"), Bytes(myString("named"))) + require.Equal(t, []byte("named"), Bytes(myBytes("named"))) + require.Empty(t, Bytes("")) + require.Empty(t, Bytes([]byte(nil))) + + // The []byte view of a []byte input aliases the original storage. + src := []byte("alias") + view := Bytes(src) + require.Same(t, &src[0], &view[0]) +} + +func Test_Seq(t *testing.T) { + t.Parallel() + b := []byte("round trip") + require.Equal(t, "round trip", Seq[string](b)) + require.Equal(t, []byte("round trip"), Seq[[]byte](b)) + require.Equal(t, myString("round trip"), Seq[myString](b)) + require.Empty(t, Seq[string](nil)) + require.Empty(t, Seq[[]byte](nil)) + + // Both instantiations are views over the same storage, not copies. + viewB := Seq[[]byte](b) + require.Same(t, &b[0], &viewB[0]) + + // Seq is Bytes' inverse for every instantiation. + require.Equal(t, "inverse", Seq[string](Bytes("inverse"))) + require.Equal(t, []byte("inverse"), Seq[[]byte](Bytes([]byte("inverse")))) +} diff --git a/ipparse.go b/ipparse.go new file mode 100644 index 0000000..2469b49 --- /dev/null +++ b/ipparse.go @@ -0,0 +1,171 @@ +package utils + +import ( + "bytes" + "encoding/binary" + "net/netip" + + "github.com/gofiber/utils/v2/internal/unsafeconv" +) + +// ParseIPv4 parses a dotted-decimal IPv4 address into a netip.Addr. It +// accepts exactly the IPv4 strings netip.ParseAddr does — four octets 0-255, +// no leading zeros, nothing before or after — and reports ok=false for +// everything else, including IPv6 forms. It never allocates, so []byte +// callers skip both the string conversion and netip's error construction. +func ParseIPv4[S byteSeq](s S) (netip.Addr, bool) { + var b [4]byte + n := len(s) + i := 0 + for oct := range 4 { + if oct > 0 { + if i >= n || s[i] != '.' { + return netip.Addr{}, false + } + i++ + } + if i >= n { + return netip.Addr{}, false + } + c := s[i] - '0' + if c > 9 { + return netip.Addr{}, false + } + v := uint32(c) + i++ + digits := 1 + for i < n && digits < 3 { + c = s[i] - '0' + if c > 9 { + break + } + v = v*10 + uint32(c) + i++ + digits++ + } + // Reject values above 255 and, like netip, leading zeros. + if v > 255 || (digits > 1 && s[i-digits] == '0') { + return netip.Addr{}, false + } + b[oct] = byte(v) + } + if i != n { + return netip.Addr{}, false + } + return netip.AddrFrom4(b), true +} + +// ParseIPv6 parses an IPv6 address into a netip.Addr. It accepts exactly +// the IPv6 strings netip.ParseAddr does — 16-bit hex fields, one optional +// "::", an optional embedded dotted-decimal IPv4 tail, and an optional +// non-empty "%zone" suffix — and reports ok=false for everything else, +// including plain IPv4 forms (use ParseIPv4 for those). The parse itself +// never allocates; only a present zone is materialized as a string because +// netip.Addr stores zones as strings. +func ParseIPv6[S byteSeq](s S) (netip.Addr, bool) { + n := len(s) + zone := bytes.IndexByte(unsafeconv.Bytes(s), '%') + end := n + if zone >= 0 { + if zone == n-1 { + // Empty zone. + return netip.Addr{}, false + } + end = zone + } + + var ip [16]byte + off := 0 + ellipsis := -1 + i := 0 + if end >= 2 && s[0] == ':' && s[1] == ':' { + ellipsis = 0 + i = 2 + } else if end > 0 && s[0] == ':' { + // A single leading colon starts no field. + return netip.Addr{}, false + } + + for off < 16 && i < end { + // Parse up to four hex digits; a fifth is an error. + fieldStart := i + val := uint32(0) + lim := min(i+4, end) + for i < lim { + d := unhexTable[s[i]] + if d == 0xFF { + break + } + val = val<<4 | uint32(d) + i++ + } + if i == lim && i < end && unhexTable[s[i]] != 0xFF { + return netip.Addr{}, false + } + if i < end && s[i] == '.' { + // Embedded IPv4 tail filling the final four bytes. + if off > 12 { + return netip.Addr{}, false + } + v4, ok := ParseIPv4(s[fieldStart:end]) + if !ok { + return netip.Addr{}, false + } + b4 := v4.As4() + copy(ip[off:], b4[:]) + off += 4 + i = end + break + } + if i == fieldStart { + // Every colon-separated field needs at least one digit. + return netip.Addr{}, false + } + // off is always even and < 16 here, so the two-byte store fits. + binary.BigEndian.PutUint16(ip[off:], uint16(val)) + off += 2 + if i == end { + break + } + if s[i] != ':' { + return netip.Addr{}, false + } + i++ + if i < end && s[i] == ':' { + if ellipsis >= 0 { + // At most one "::". + return netip.Addr{}, false + } + ellipsis = off + i++ + continue + } + if i == end { + // A trailing single colon is invalid. + return netip.Addr{}, false + } + } + if i != end { + return netip.Addr{}, false + } + if off < 16 { + if ellipsis < 0 { + return netip.Addr{}, false + } + // Expand "::": shift the fields after it to the end, zero the gap. + gap := 16 - off + for j := off - 1; j >= ellipsis; j-- { + ip[j+gap] = ip[j] + ip[j] = 0 + } + } else if ellipsis >= 0 { + // "::" must stand for at least one field of zeros. + return netip.Addr{}, false + } + + addr := netip.AddrFrom16(ip) + if zone >= 0 { + addr = addr.WithZone(string(s[zone+1:])) + } + return addr, true +} diff --git a/ipparse_test.go b/ipparse_test.go new file mode 100644 index 0000000..a4c585e --- /dev/null +++ b/ipparse_test.go @@ -0,0 +1,151 @@ +package utils + +import ( + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func parseIPSamples() []string { + return []string{ + // IPv4 + "0.0.0.0", + "127.0.0.1", + "203.0.113.195", + "255.255.255.255", + "1.2.3.4", + "256.1.1.1", + "1.2.3.4.5", + "1.2.3", + "01.2.3.4", + "1.02.3.4", + "1.2.3.04", + "1.2.3.4 ", + " 1.2.3.4", + "1..3.4", + "1.2.3.", + ".1.2.3", + "1.2.3.4567", + "a.b.c.d", + "1.2.3.-4", + "", + // IPv6 + "::", + "::1", + "1::", + "2001:db8::8a2e:370:7334", + "2001:0db8:0000:0000:0000:ff00:0042:8329", + "1:2:3:4:5:6:7:8", + "1:2:3:4:5:6:7:8:9", + "1:2:3:4:5:6:7::", + "1::2:3:4:5:6:7:8", + "1:::2", + "1::2::3", + ":1:2:3", + "1:2:3:", + ":", + ":::", + "fe80::1%eth0", + "fe80::1%", + "%eth0", + "::%25eth0", + "::ffff:192.168.0.1", + "::ffff:1.2.3.400", + "1:2:3:4:5:6:1.2.3.4", + "1:2:3:4:5:6:7:1.2.3.4", + "::1.2.3.4", + "1.2.3.4::", + "12345::", + "g::1", + "FE80::A:b", + "0:0:0:0:0:0:0:0", + "1:2::10.0.0.1", + "::.1.2.3", + } +} + +func Test_ParseIPv4(t *testing.T) { + t.Parallel() + for _, in := range parseIPSamples() { + want, wantErr := netip.ParseAddr(in) + wantOK := wantErr == nil && !strings.Contains(in, ":") + got, ok := ParseIPv4(in) + gotBytes, okBytes := ParseIPv4([]byte(in)) + require.Equal(t, wantOK, ok, "input %q", in) + require.Equal(t, wantOK, okBytes, "input %q", in) + if wantOK { + require.Equal(t, want, got, "input %q", in) + require.Equal(t, want, gotBytes, "input %q", in) + } else { + require.Equal(t, netip.Addr{}, got, "input %q", in) + } + } +} + +func Test_ParseIPv6(t *testing.T) { + t.Parallel() + for _, in := range parseIPSamples() { + want, wantErr := netip.ParseAddr(in) + wantOK := wantErr == nil && strings.Contains(in, ":") + got, ok := ParseIPv6(in) + gotBytes, okBytes := ParseIPv6([]byte(in)) + require.Equal(t, wantOK, ok, "input %q", in) + require.Equal(t, wantOK, okBytes, "input %q", in) + if wantOK { + require.Equal(t, want, got, "input %q", in) + require.Equal(t, want, gotBytes, "input %q", in) + } else { + require.Equal(t, netip.Addr{}, got, "input %q", in) + } + } +} + +func Benchmark_ParseIPv4(b *testing.B) { + input := "203.0.113.195" + b.Run("fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, ok := ParseIPv4(input); !ok { + b.Fatal("failed to parse") + } + } + }) + b.Run("default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := netip.ParseAddr(input); err != nil { + b.Fatal(err) + } + } + }) +} + +func Benchmark_ParseIPv6(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"compressed", "2001:db8::8a2e:370:7334"}, + {"full", "2001:0db8:0000:0000:0000:ff00:0042:8329"}, + } + for _, input := range inputs { + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, ok := ParseIPv6(input.value); !ok { + b.Fatal("failed to parse") + } + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := netip.ParseAddr(input.value); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/jsonstring.go b/jsonstring.go new file mode 100644 index 0000000..d8b5297 --- /dev/null +++ b/jsonstring.go @@ -0,0 +1,124 @@ +package utils + +import ( + "unicode/utf8" + + "github.com/gofiber/utils/v2/internal/unsafeconv" + "github.com/gofiber/utils/v2/swar" +) + +// ltLanes, gtLanes, and ampLanes broadcast the HTML-escaped characters to +// every lane; quoteLanes and backslashLanes live in ascii.go. +const ( + ltLanes = uint64('<') * swar.Ones + gtLanes = uint64('>') * swar.Ones + ampLanes = uint64('&') * swar.Ones +) + +// jsonHexDigits is the escape alphabet for \u00XX sequences, matching +// encoding/json. +const jsonHexDigits = "0123456789abcdef" + +// jsonSafeTable flags the ASCII bytes that pass through a JSON string +// encoder verbatim: 0x20..0x7E minus '"', '\\', and the HTML-escaped +// '<', '>', '&'. Bytes >= 0x80 are false so the scalar loop routes them +// to the UTF-8 path. +var jsonSafeTable = buildJSONSafeTable() + +func buildJSONSafeTable() [256]bool { + var t [256]bool + for c := byte(0x20); c < utf8.RuneSelf; c++ { + t[c] = c != '"' && c != '\\' && c != '<' && c != '>' && c != '&' + } + return t +} + +// AppendJSONString appends s to dst as a double-quoted JSON string and +// returns the extended slice. The output is byte-identical to +// encoding/json.Marshal of the same string value, including its default +// HTML escaping: '"' and '\\' get backslash escapes; control bytes below +// 0x20 become \b, \f, \n, \r, \t, or \u00XX; '<', '>', '&' become \u00XX; each +// invalid UTF-8 byte becomes the six literal characters \ufffd; and the +// line separators U+2028/U+2029 become the \u2028 and \u2029 escapes. +// All other bytes, including multi-byte UTF-8 sequences, are copied +// verbatim. dst must not alias s: the output is longer than the input (at +// minimum by the surrounding quotes), so in-place encoding is impossible +// and an aliased dst would overwrite bytes before they are read. Clean +// spans are located with a SWAR scan and copied wholesale, so typical log +// or header values cost one scan and one copy — with none of +// encoding/json.Marshal's reflection or allocation. +func AppendJSONString[S byteSeq](dst []byte, s S) []byte { + bs := unsafeconv.Bytes(s) + n := len(bs) + dst = append(dst, '"') + start, i := 0, 0 + for i < n { + // Skip to the next byte needing attention. Like nonQuotableMask, + // the combined mask is exact in and below its first set lane, which + // is all this first-match scan consumes. + if i+8 <= n { + m := jsonUnsafeMask(swar.Load8(bs, i)) + if m == 0 { + i += 8 + continue + } + i += swar.FirstLane(m) + } else if c := bs[i]; jsonSafeTable[c] { + i++ + continue + } + c := bs[i] + if c < utf8.RuneSelf { + dst = append(dst, bs[start:i]...) + dst = append(dst, '\\') + switch c { + case '"', '\\': + dst = append(dst, c) + case '\b': + dst = append(dst, 'b') + case '\f': + dst = append(dst, 'f') + case '\n': + dst = append(dst, 'n') + case '\r': + dst = append(dst, 'r') + case '\t': + dst = append(dst, 't') + default: + // Other control bytes and the HTML characters '<', '>', '&'. + dst = append(dst, 'u', '0', '0', jsonHexDigits[c>>4], jsonHexDigits[c&0x0F]) + } + i++ + start = i + continue + } + r, size := utf8.DecodeRune(bs[i:]) + switch { + case r == utf8.RuneError && size == 1: + dst = append(dst, bs[start:i]...) + dst = append(dst, '\\', 'u', 'f', 'f', 'f', 'd') + i++ + start = i + case r == '\u2028' || r == '\u2029': + dst = append(dst, bs[start:i]...) + dst = append(dst, '\\', 'u', '2', '0', '2', byte('8'+r-'\u2028')) + i += size + start = i + default: + i += size + } + } + dst = append(dst, bs[start:]...) + return append(dst, '"') +} + +// jsonUnsafeMask flags the lanes of w holding bytes a JSON string encoder +// cannot copy verbatim: controls below 0x20 (exact range mask), the five +// escaped ASCII characters (ZeroLanes needle matches, exact in and below +// their first hit), and all bytes >= 0x80, which the caller inspects as +// UTF-8. +func jsonUnsafeMask(w uint64) uint64 { + return swar.MatchRangeMask(w, 0x00, 0x1F) | w&swar.HighBits | + swar.ZeroLanes(w^quoteLanes) | swar.ZeroLanes(w^backslashLanes) | + swar.ZeroLanes(w^ltLanes) | swar.ZeroLanes(w^gtLanes) | swar.ZeroLanes(w^ampLanes) +} diff --git a/jsonstring_test.go b/jsonstring_test.go new file mode 100644 index 0000000..da2f584 --- /dev/null +++ b/jsonstring_test.go @@ -0,0 +1,77 @@ +package utils + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func jsonStringInputs() []string { + return []string{ + "", + "plain ascii value", + "with \"quotes\" and \\backslashes\\", + "tabs\tnewlines\nreturns\r", + "controls \x00\x01\x1f\x7f", + "html bold & more", + "café naïve 日本語", + "line\u2028and\u2029separators", + "invalid \xff\xfe utf8 \xc3(", + "trailing continuation \xc3", + strings.Repeat("0123456789abcdef", 8), + strings.Repeat("x", 7) + "\"" + strings.Repeat("y", 9), + } +} + +func Test_AppendJSONString(t *testing.T) { + t.Parallel() + for _, in := range jsonStringInputs() { + want, err := json.Marshal(in) + require.NoError(t, err) + require.Equal(t, string(want), string(AppendJSONString(nil, in)), "input %q", in) + require.Equal(t, string(want), string(AppendJSONString(nil, []byte(in))), "input %q", in) + } + + // Every single byte value agrees with encoding/json. + for i := range 256 { + in := string([]byte{byte(i)}) + want, err := json.Marshal(in) + require.NoError(t, err) + require.Equal(t, string(want), string(AppendJSONString(nil, in)), "byte %#x", i) + } + + // Appending must preserve existing dst content. + got := AppendJSONString([]byte(`{"msg":`), "hi \"there\"") + require.Equal(t, `{"msg":"hi \"there\""`, string(got)) //nolint:testifylint // byte-exact comparison is the contract; JSONEq would hide encoding differences +} + +func Benchmark_AppendJSONString(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"clean-16B", "GET /index.html"}, + {"clean-64B", strings.Repeat("clean-log", 7) + "x"}, + {"escaped-64B", strings.Repeat(`says "hi" `, 6) + "done"}, + {"unicode-64B", strings.Repeat("café 日本 ", 6) + "end"}, + } + for _, input := range inputs { + dst := make([]byte, 0, 3*len(input.value)) + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + AppendJSONString(dst, input.value) + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := json.Marshal(input.value); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/url.go b/url.go new file mode 100644 index 0000000..fd2bbcf --- /dev/null +++ b/url.go @@ -0,0 +1,167 @@ +package utils + +import ( + "bytes" + "net/url" + "strings" + + "github.com/gofiber/utils/v2/internal/unsafeconv" +) + +// escapeMode selects between net/url's query-component and path-segment +// escaping dialects: only the query dialect maps '+' to space. +type escapeMode uint8 + +const ( + escapeQuery escapeMode = iota + escapePath +) + +// upperHexDigits is the escape alphabet used by the Append*Escape helpers, +// matching net/url. +const upperHexDigits = "0123456789ABCDEF" + +// unhexTable maps an ASCII byte to its hexadecimal digit value, or 0xFF for +// bytes that are not hex digits. +var unhexTable = buildUnhexTable() + +func buildUnhexTable() [256]byte { + var t [256]byte + for i := range t { + t[i] = 0xFF + } + for c := byte('0'); c <= '9'; c++ { + t[c] = c - '0' + } + for c := byte('a'); c <= 'f'; c++ { + t[c] = c - 'a' + 10 + } + for c := byte('A'); c <= 'F'; c++ { + t[c] = c - 'A' + 10 + } + return t +} + +// queryNoEscapeTable and pathNoEscapeTable flag the bytes net/url leaves +// verbatim in query components and path segments respectively. They are +// derived from net/url's shouldEscape rules and pinned to them by +// exhaustive per-byte tests. Path segments additionally keep the sub-delims +// net/url's encodePathSegment mode does not escape. +var ( + queryNoEscapeTable = buildNoEscapeTable("") + pathNoEscapeTable = buildNoEscapeTable("$&+:=@") +) + +// buildNoEscapeTable flags the RFC 3986 unreserved bytes plus alsoSafe as +// not needing escape. +func buildNoEscapeTable(alsoSafe string) [256]bool { + var t [256]bool + for i := range 256 { + c := byte(i) + t[i] = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '-' || c == '_' || c == '.' || c == '~' || + strings.IndexByte(alsoSafe, c) >= 0 + } + return t +} + +// AppendQueryEscape appends the percent-encoded form of s to dst and returns +// the extended slice. The output is byte-identical to net/url.QueryEscape: +// unreserved bytes pass through, space becomes '+', everything else becomes +// %XX with uppercase hex. dst must not alias s: escaping can grow the input, +// so in-place operation is impossible. +func AppendQueryEscape[S byteSeq](dst []byte, s S) []byte { + return appendEscape(dst, unsafeconv.Bytes(s), &queryNoEscapeTable, escapeQuery) +} + +// AppendPathEscape appends the percent-encoded path-segment form of s to dst +// and returns the extended slice. The output is byte-identical to +// net/url.PathEscape; the aliasing rule matches AppendQueryEscape. +func AppendPathEscape[S byteSeq](dst []byte, s S) []byte { + return appendEscape(dst, unsafeconv.Bytes(s), &pathNoEscapeTable, escapePath) +} + +// appendEscape copies runs of unescaped bytes wholesale and expands the rest, +// in one pass with no intermediate allocation — unlike net/url, which counts +// in a first pass and then rebuilds the string byte by byte. +func appendEscape(dst, s []byte, noEscape *[256]bool, mode escapeMode) []byte { + i, n := 0, len(s) + for i < n { + j := i + for j < n && noEscape[s[j]] { + j++ + } + dst = append(dst, s[i:j]...) + if j == n { + break + } + if c := s[j]; mode == escapeQuery && c == ' ' { + dst = append(dst, '+') + } else { + dst = append(dst, '%', upperHexDigits[c>>4], upperHexDigits[c&0x0F]) + } + i = j + 1 + } + return dst +} + +// AppendQueryUnescape appends the decoded form of the query component s to +// dst and returns the extended slice, converting '+' to space and %XX to the +// byte it encodes, exactly like net/url.QueryUnescape including its +// url.EscapeError values for malformed escapes. On error the returned slice +// is dst with its original length (its backing array may still have been +// reallocated by growth). Decoding never grows the input, so dst may be +// s[:0] on a common backing array to decode in place; any other overlap is +// invalid. Note that an in-place decode that fails has already overwritten +// the prefix of s before the malformed escape with decoded bytes — treat s +// as consumed once an in-place decode starts, error or not. +func AppendQueryUnescape[S byteSeq](dst []byte, s S) ([]byte, error) { + return appendUnescape(dst, unsafeconv.Bytes(s), escapeQuery) +} + +// AppendPathUnescape appends the decoded form of the path component s to dst +// and returns the extended slice, decoding %XX and leaving '+' verbatim, +// exactly like net/url.PathUnescape. Error and aliasing behavior match +// AppendQueryUnescape. +func AppendPathUnescape[S byteSeq](dst []byte, s S) ([]byte, error) { + return appendUnescape(dst, unsafeconv.Bytes(s), escapePath) +} + +// appendUnescape jumps between escape sites with vectorized scans — +// IndexAny2 (SWAR/AVX2) when '+' also needs rewriting, bytes.IndexByte +// otherwise — and copies the clean spans between them wholesale, so input +// without escapes costs one scan and one copy. +func appendUnescape(dst, s []byte, mode escapeMode) ([]byte, error) { + off := len(dst) + i, n := 0, len(s) + for i < n { + var j int + if mode == escapeQuery { + j = IndexAny2(s[i:], '%', '+') + } else { + j = bytes.IndexByte(s[i:], '%') + } + if j < 0 { + return append(dst, s[i:]...), nil + } + j += i + dst = append(dst, s[i:j]...) + if s[j] == '+' { + dst = append(dst, ' ') + i = j + 1 + continue + } + // s[j] == '%': the error substrings below match net/url's unescape. + if j+2 >= n { + return dst[:off], url.EscapeError(s[j:]) + } + hi := unhexTable[s[j+1]] + lo := unhexTable[s[j+2]] + if hi > 0x0F || lo > 0x0F { + return dst[:off], url.EscapeError(s[j : j+3]) + } + dst = append(dst, hi<<4|lo) + i = j + 3 + } + return dst, nil +} diff --git a/url_test.go b/url_test.go new file mode 100644 index 0000000..8cae0d3 --- /dev/null +++ b/url_test.go @@ -0,0 +1,179 @@ +package utils + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_AppendQueryEscape(t *testing.T) { + t.Parallel() + // Exhaustive single-byte agreement with net/url pins the escape tables. + for i := range 256 { + in := string([]byte{byte(i)}) + require.Equal(t, url.QueryEscape(in), string(AppendQueryEscape(nil, in)), "byte %#x", i) + require.Equal(t, url.PathEscape(in), string(AppendPathEscape(nil, in)), "byte %#x", i) + } + + inputs := []string{ + "", + "shortcuts", + "hello world & goodbye", + "a=1&b=2?c=/d", + "héllo wörld", + "100%+tax", + "/products/42/reviews", + strings.Repeat("clean-token.v2~x_", 8), + "\x00\x1f\x7f\xff", + } + for _, in := range inputs { + require.Equal(t, url.QueryEscape(in), string(AppendQueryEscape(nil, in)), "input %q", in) + require.Equal(t, url.QueryEscape(in), string(AppendQueryEscape(nil, []byte(in))), "input %q", in) + require.Equal(t, url.PathEscape(in), string(AppendPathEscape(nil, in)), "input %q", in) + require.Equal(t, url.PathEscape(in), string(AppendPathEscape(nil, []byte(in))), "input %q", in) + } + + // Appending must preserve existing dst content. + got := AppendQueryEscape([]byte("q="), "a b") + require.Equal(t, "q=a+b", string(got)) +} + +func Test_AppendQueryUnescape(t *testing.T) { + t.Parallel() + inputs := []string{ + "", + "plain", + "a+b", + "%41%42%43", + "a%20b+c", + "%2Fpath%2fmixed", + "caf%C3%A9", + "trailing%", + "trailing%2", + "%zz", + "%1g", + "%%41", + "100%", + strings.Repeat("no-escapes-here-at-all.", 8), + strings.Repeat("%41+", 32), + } + for _, in := range inputs { + wantQ, wantQErr := url.QueryUnescape(in) + gotQ, errQ := AppendQueryUnescape(nil, in) + gotQB, errQB := AppendQueryUnescape(nil, []byte(in)) + wantP, wantPErr := url.PathUnescape(in) + gotP, errP := AppendPathUnescape(nil, in) + if wantQErr != nil { + require.Equal(t, wantQErr, errQ, "query input %q", in) + require.Equal(t, wantQErr, errQB, "query input %q", in) + require.Empty(t, gotQ, "query input %q", in) + } else { + require.NoError(t, errQ, "query input %q", in) + require.Equal(t, wantQ, string(gotQ), "query input %q", in) + require.Equal(t, wantQ, string(gotQB), "query input %q", in) + } + if wantPErr != nil { + require.Equal(t, wantPErr, errP, "path input %q", in) + require.Empty(t, gotP, "path input %q", in) + } else { + require.NoError(t, errP, "path input %q", in) + require.Equal(t, wantP, string(gotP), "path input %q", in) + } + } + + // On error the original dst content survives with its original length. + got, err := AppendQueryUnescape([]byte("keep"), "%zz") + require.Error(t, err) + require.Equal(t, "keep", string(got)) + + // In-place decode: dst = src[:0] on the same backing array. + buf := []byte("a%20b+c") + out, err := AppendQueryUnescape(buf[:0], buf) + require.NoError(t, err) + require.Equal(t, "a b c", string(out)) +} + +func Benchmark_AppendQueryEscape(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"clean-64B", strings.Repeat("token-64", 8)}, + {"mixed-64B", strings.Repeat("a b&c=d ", 8)}, + } + for _, input := range inputs { + dst := make([]byte, 0, 3*len(input.value)) + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + AppendQueryEscape(dst, input.value) + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + url.QueryEscape(input.value) + } + }) + } +} + +func Benchmark_AppendQueryUnescape(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"plain-64B", strings.Repeat("noescape", 8)}, + {"escaped-64B", strings.Repeat("a%20b+cd", 8)}, + } + for _, input := range inputs { + dst := make([]byte, 0, len(input.value)) + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := AppendQueryUnescape(dst, input.value); err != nil { + b.Fatal(err) + } + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := url.QueryUnescape(input.value); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func Benchmark_AppendPathUnescape(b *testing.B) { + inputs := []struct { + name string + value string + }{ + {"plain-25B", "/products/42/reviews/new!"}, + {"escaped-31B", "/products/caf%C3%A9%20bar/9%2F9"}, + } + for _, input := range inputs { + dst := make([]byte, 0, len(input.value)) + b.Run(input.name+"/fiber", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := AppendPathUnescape(dst, input.value); err != nil { + b.Fatal(err) + } + } + }) + b.Run(input.name+"/default", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := url.PathUnescape(input.value); err != nil { + b.Fatal(err) + } + } + }) + } +}