mirror of
https://github.com/soypat/lneto.git
synced 2026-08-01 21:47:50 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4517010070 | |||
| 1884cfc9b7 | |||
| 5c54030f19 | |||
| a3f2742abf | |||
| 8873b55e5d | |||
| 41c7e3c445 | |||
| 1bbfd5eac3 | |||
| 1a474154cb | |||
| 347cef9ba5 | |||
| 157a8537a2 | |||
| 766e03e90e | |||
| ab1a0c735a | |||
| 3c1f0e0281 | |||
| 99e9d90a60 | |||
| 4fc53a84cd | |||
| 104a2b0b26 | |||
| e5f8ac0687 | |||
| 3b5b010673 | |||
| 4d6363da00 | |||
| cdc99fb9ff | |||
| 96a02e7d2c | |||
| b795d6a437 |
@@ -0,0 +1,90 @@
|
||||
name: Benchmark Comment
|
||||
|
||||
# Rationale: This more privileged workflow runs in the base repo's
|
||||
# context (with a write token) only after the 'untrusted' CI workflow finishes.
|
||||
# It does NOT check out or execute PR code; it only consumes the benchmark
|
||||
# report artifact as inert, validated data.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
# Only for PR-triggered CI runs that succeeded to suppress noise
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to comment
|
||||
steps:
|
||||
- name: Download generated benchmark report
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.1
|
||||
with:
|
||||
name: bench-report
|
||||
path: bench-report
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Validate report and upsert PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
report="bench-report/bench-report.md"
|
||||
marker='<!-- lneto-bench -->'
|
||||
|
||||
# Treat the artifact as untrusted input: it was produced by a job that
|
||||
# ran PR code. Basic validation before posting. TODO: Make more robust.
|
||||
test -f "$report"
|
||||
size="$(wc -c < "$report")"
|
||||
if [ "$size" -le 0 ] || [ "$size" -gt 1000000 ]; then
|
||||
echo "::error::benchmark report has unexpected size: ${size} bytes"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qF "$marker" "$report"; then
|
||||
echo "::error::benchmark report missing marker ${marker}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefer the PR number from the workflow_run payload, which ties this
|
||||
# privileged workflow to the PR-triggered CI run. Fall back to the
|
||||
# run's head SHA/branch only when GitHub does not populate it.
|
||||
pr="$PR_NUMBER"
|
||||
if [ -z "$pr" ]; then
|
||||
pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \
|
||||
--jq 'map(select(.state == "open")) | .[0].number // empty')"
|
||||
fi
|
||||
if [ -z "$pr" ]; then
|
||||
pr="$(gh api --method GET "repos/${REPO}/pulls" \
|
||||
-f state=open \
|
||||
-f head="${HEAD_OWNER}:${HEAD_BRANCH}" \
|
||||
--jq '.[0].number // empty')"
|
||||
fi
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open PR found for ${HEAD_SHA}; nothing to comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
comment_id="$(
|
||||
gh api "repos/${REPO}/issues/${pr}/comments" --paginate \
|
||||
--jq ".[] | select(.body | contains(\"${marker}\")) | .id" | head -n1
|
||||
)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \
|
||||
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
|
||||
else
|
||||
gh api --method POST "repos/${REPO}/issues/${pr}/comments" \
|
||||
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
|
||||
fi
|
||||
+14
-14
@@ -104,7 +104,7 @@ jobs:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -115,20 +115,20 @@ jobs:
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem -count=5 -shuffle=on -run='^$' -timeout=15m ./... | tee bench-results.txt
|
||||
|
||||
# Fails for a weird reason, will be investigated in the future
|
||||
# - name: Report benchmark regressions
|
||||
# uses: benchmark-action/github-action-benchmark@a887eba2af2000fda74e733fcf952aa823b65916 # v1.21.0
|
||||
# with:
|
||||
# tool: go
|
||||
# output-file-path: bench-results.txt
|
||||
# summary-always: true
|
||||
# comment-on-alert: true
|
||||
# alert-threshold: "120%"
|
||||
# fail-on-alert: false
|
||||
# auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate benchmark report
|
||||
run: |
|
||||
go run ./internal/benchci -current bench-results.txt -out bench-report.md
|
||||
cat bench-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload benchmark results
|
||||
- name: Upload generated benchmark report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bench-report
|
||||
path: bench-report.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload raw benchmark results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
+10
-1
@@ -1,3 +1,12 @@
|
||||
# Ignore files with no extensions trick:
|
||||
# First ignore all.
|
||||
*
|
||||
# Unignore directories since they usually have no extensions
|
||||
!*/
|
||||
# Unignore all with extensions.
|
||||
!*.*
|
||||
!LICENSE
|
||||
|
||||
# Go workspaces.
|
||||
go.work
|
||||
go.work.sum
|
||||
@@ -22,7 +31,7 @@ vendor/
|
||||
*.dylib
|
||||
*.hex
|
||||
*.wasm
|
||||
|
||||
/http-linux
|
||||
# Profiling
|
||||
*.pprof
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Userspace networking primitives.
|
||||
- Zero scheduling required. No goroutines/channels use in Lneto. Can be run in event loop.
|
||||
- Heapless packet processing
|
||||
- [`httpraw`](https://github.com/soypat/lneto/tree/main/http/httpraw) is likely the most performant HTTP/1.1 processing package in the Go ecosystem. Based on [`fasthttp`](https://github.com/valyala/fasthttp) but simpler and more thoughtful memory use.
|
||||
- [`httφ`](https://github.com/soypat/lneto/tree/main/http/httphi) - Heapless HTTP router and zero-copy response writing with Go's standard library API.
|
||||
- Lean memory footprint
|
||||
- HTTP header struct is 80 bytes with no runtime usage nor heap usage other than buffer
|
||||
- Entire Ethernet+IPv4+UDP+DHCP+DNS+NTP stack in ~2kB RAM.
|
||||
@@ -38,7 +39,7 @@ go run ./examples/gen/gen-binary-bench # generate table
|
||||
|
||||
| Program | Extra Protocols | Packet capture printing | amd64 Go | WASM Go | amd64 TinyGo | WASM TinyGo | Pico TinyGo |
|
||||
|---|:---:|:---:|---|---|---|---|---|
|
||||
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.4MB | 1.6MB | 1.2MB | 189kB |
|
||||
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.5MB | 1.6MB | 1.2MB | 192kB |
|
||||
| [Gvisor MWE w/ go-net](./examples/_import_examples/gvisor-mwe/) | None | ❌ | 6.6MB | 7.5MB | DNC | DNC | DNC |
|
||||
## `xcurl` example
|
||||
You may try lneto out on linux with the [xcurl example](./examples/xcurl/) which gets an HTTP page by doing all the low-level networking part using absolutely no standard library.
|
||||
@@ -109,7 +110,9 @@ ok github.com/soypat/lneto/x/xnet 2.926s
|
||||
| Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO |
|
||||
| IPv6 | RFC 8200 | ✅ | `ipv6` | — | Frame parsing and stack handling |
|
||||
| ICMPv6 | RFC 4443 | ✅ | `ipv6/icmpv6` | — | Echo+NDP frame parsing and stack handling |
|
||||
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Frame parsing and standalone handling |
|
||||
| DHCPv6 DNS Configuration | RFC 3646 | 🟡 | `dhcp/dhcpv6` | — | Recursive DNS server and domain search options parsed and exposed |
|
||||
| DHCPv6 NTP Configuration | RFC 5908 | ✅ | `dhcp/dhcpv6` | — | NTP server address, multicast address, and FQDN suboptions parsed and exposed |
|
||||
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Client IA_NA/IA_PD request handling and reconfigure-renew; no relay agent or dynamic server pools |
|
||||
| TLS 1.3 | RFC 8446 | ❌ | — | — | Not implemented |
|
||||
|
||||
¹ `BenchmarkARPExchange` — full ARP request/response exchange over Ethernet: **0 B/op, 0 allocs/op**
|
||||
|
||||
+4
-4
@@ -17,9 +17,9 @@ const _Operation_name = "requestreply"
|
||||
var _Operation_index = [...]uint8{0, 7, 12}
|
||||
|
||||
func (i Operation) String() string {
|
||||
i -= 1
|
||||
if i >= Operation(len(_Operation_index)-1) {
|
||||
return "Operation(" + strconv.FormatInt(int64(i+1), 10) + ")"
|
||||
idx := int(i) - 1
|
||||
if i < 1 || idx >= len(_Operation_index)-1 {
|
||||
return "Operation(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Operation_name[_Operation_index[i]:_Operation_index[i+1]]
|
||||
return _Operation_name[_Operation_index[idx]:_Operation_index[idx+1]]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ type Frame struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// RawData returns the underlying frame buffer.
|
||||
func (frm Frame) RawData() []byte { return frm.buf }
|
||||
|
||||
// OptionsPayload returns the options portion of the DHCP frame. May be zero lengthed.
|
||||
func (frm Frame) OptionsPayload() []byte {
|
||||
return frm.buf[OptionsOffset:]
|
||||
|
||||
@@ -106,10 +106,11 @@ const _Op_name = "undefinedrequestreply"
|
||||
var _Op_index = [...]uint8{0, 9, 16, 21}
|
||||
|
||||
func (i Op) String() string {
|
||||
if i >= Op(len(_Op_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_Op_index)-1 {
|
||||
return "Op(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Op_name[_Op_index[i]:_Op_index[i+1]]
|
||||
return _Op_name[_Op_index[idx]:_Op_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -131,10 +132,11 @@ const _MessageType_name = "undefineddiscoverofferrequestdeclineacknakreleaseinfo
|
||||
var _MessageType_index = [...]uint8{0, 9, 17, 22, 29, 36, 39, 42, 49, 55}
|
||||
|
||||
func (i MessageType) String() string {
|
||||
if i >= MessageType(len(_MessageType_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_MessageType_index)-1 {
|
||||
return "MessageType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _MessageType_name[_MessageType_index[i]:_MessageType_index[i+1]]
|
||||
return _MessageType_name[_MessageType_index[idx]:_MessageType_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -155,9 +157,9 @@ const _ClientState_name = "initselectingrequestingboundrenewingrebindinginit-reb
|
||||
var _ClientState_index = [...]uint8{0, 4, 13, 23, 28, 36, 45, 56, 65}
|
||||
|
||||
func (i ClientState) String() string {
|
||||
i -= 1
|
||||
if i >= ClientState(len(_ClientState_index)-1) {
|
||||
return "ClientState(" + strconv.FormatInt(int64(i+1), 10) + ")"
|
||||
idx := int(i) - 1
|
||||
if i < 1 || idx >= len(_ClientState_index)-1 {
|
||||
return "ClientState(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _ClientState_name[_ClientState_index[i]:_ClientState_index[i+1]]
|
||||
return _ClientState_name[_ClientState_index[idx]:_ClientState_index[idx+1]]
|
||||
}
|
||||
|
||||
+337
-8
@@ -6,14 +6,77 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/dns"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// Default caps for the repeated, server-supplied options the client retains.
|
||||
// A DHCPv6 server may place arbitrarily many of each in a single message
|
||||
// (RFC 8415), so the client bounds them to prevent a malicious or
|
||||
// misconfigured server from driving unbounded allocation (an OOM vector).
|
||||
const (
|
||||
defaultMaxDNSServers = 4
|
||||
defaultMaxDomainSearch = 6
|
||||
defaultMaxNTPServers = 4
|
||||
defaultMaxNTPMulticastServers = 2
|
||||
defaultMaxNTPServerNames = 4
|
||||
defaultMaxDelegatedPrefixes = 4
|
||||
)
|
||||
|
||||
// Limits bounds how many entries of each repeated, server-supplied option the
|
||||
// client retains from a single exchange. The backing arrays are sized to these
|
||||
// caps once in [Client.BeginRequest] and reused across resets, so parsing a
|
||||
// server message performs no further allocation and a hostile server cannot
|
||||
// grow client memory without bound. A zero (or negative) field selects its
|
||||
// default; see the default* constants.
|
||||
type Limits struct {
|
||||
MaxDNSServers int // OptDNSServers addresses.
|
||||
MaxDomainSearch int // OptDomainList search names.
|
||||
MaxNTPServers int // OptNTPServer suboption 1 addresses.
|
||||
MaxNTPMulticastServers int // OptNTPServer suboption 2 addresses.
|
||||
MaxNTPServerNames int // OptNTPServer suboption 3 FQDNs.
|
||||
MaxDelegatedPrefixes int // OptIAPD/OptIAPrefix delegated prefixes.
|
||||
}
|
||||
|
||||
// withDefaults returns a copy of l with every non-positive field replaced by
|
||||
// its default cap, so the resolved limits are always usable.
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxDNSServers <= 0 {
|
||||
l.MaxDNSServers = defaultMaxDNSServers
|
||||
}
|
||||
if l.MaxDomainSearch <= 0 {
|
||||
l.MaxDomainSearch = defaultMaxDomainSearch
|
||||
}
|
||||
if l.MaxNTPServers <= 0 {
|
||||
l.MaxNTPServers = defaultMaxNTPServers
|
||||
}
|
||||
if l.MaxNTPMulticastServers <= 0 {
|
||||
l.MaxNTPMulticastServers = defaultMaxNTPMulticastServers
|
||||
}
|
||||
if l.MaxNTPServerNames <= 0 {
|
||||
l.MaxNTPServerNames = defaultMaxNTPServerNames
|
||||
}
|
||||
if l.MaxDelegatedPrefixes <= 0 {
|
||||
l.MaxDelegatedPrefixes = defaultMaxDelegatedPrefixes
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// RequestConfig holds the parameters for starting a DHCPv6 exchange.
|
||||
type RequestConfig struct {
|
||||
// ClientHardwareAddr is the client's Ethernet MAC address.
|
||||
// It is used to construct the client DUID-LL and IAID.
|
||||
ClientHardwareAddr [6]byte
|
||||
// Limits bounds how many of each repeated server-supplied option the client
|
||||
// stores. The zero value selects safe defaults for every field.
|
||||
Limits Limits
|
||||
}
|
||||
|
||||
// DelegatedPrefix is an IPv6 prefix delegated by a DHCPv6 server.
|
||||
type DelegatedPrefix struct {
|
||||
Prefix netip.Prefix
|
||||
PreferredLifetime uint32
|
||||
ValidLifetime uint32
|
||||
}
|
||||
|
||||
// Client is a stateful DHCPv6 client implementing the [lneto.StackNode] interface.
|
||||
@@ -39,6 +102,21 @@ type Client struct {
|
||||
// dns accumulates DNS recursive name server addresses (OptDNSServers).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
dns []netip.Addr
|
||||
// domainSearch accumulates DNS domain search names (OptDomainList).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
domainSearch []dns.Name
|
||||
// ntps accumulates NTP server addresses (OptNTPServer, suboption 1).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntps []netip.Addr
|
||||
// ntpMulticast accumulates NTP multicast addresses (OptNTPServer, suboption 2).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntpMulticast []netip.Addr
|
||||
// ntpNames accumulates NTP server FQDNs (OptNTPServer, suboption 3).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
ntpNames []dns.Name
|
||||
// delegatedPrefixes accumulates IA_PD prefixes (OptIAPD/OptIAPrefix).
|
||||
// Client owns the backing array; cleared on reset, capacity reused.
|
||||
delegatedPrefixes []DelegatedPrefix
|
||||
|
||||
assignedAddr [16]byte
|
||||
assignedAddrValid bool
|
||||
@@ -50,9 +128,18 @@ type Client struct {
|
||||
t1, t2 uint32
|
||||
preferredLifetime uint32
|
||||
validLifetime uint32
|
||||
// IA_PD timers from the server's Advertise/Reply.
|
||||
pdT1, pdT2 uint32
|
||||
reconfigureMsg MsgType
|
||||
reconfigureOK bool
|
||||
|
||||
clientMAC [6]byte
|
||||
|
||||
// limits is the resolved (defaults-applied) cap on each repeated option.
|
||||
// It is set in BeginRequest and preserved across resets so the bounded
|
||||
// backing arrays are reused without reallocation.
|
||||
limits Limits
|
||||
|
||||
// auxbuf is a scratch buffer used during Encapsulate to avoid allocations.
|
||||
auxbuf [128]byte
|
||||
}
|
||||
@@ -70,23 +157,65 @@ func (c *Client) BeginRequest(xid uint32, cfg RequestConfig) error {
|
||||
c.clientMAC = cfg.ClientHardwareAddr
|
||||
c.iaid = [4]byte(cfg.ClientHardwareAddr[:4])
|
||||
c.xid = xid & 0xFFFFFF
|
||||
c.limits = cfg.Limits.withDefaults()
|
||||
c.reset()
|
||||
// Size the per-option backing arrays to their caps once, here, so the
|
||||
// parse path on the network-facing Demux never allocates and stored
|
||||
// entries stay bounded across the connection's lifetime.
|
||||
c.dns = ensureAddrCap(c.dns, c.limits.MaxDNSServers)
|
||||
c.ntps = ensureAddrCap(c.ntps, c.limits.MaxNTPServers)
|
||||
c.ntpMulticast = ensureAddrCap(c.ntpMulticast, c.limits.MaxNTPMulticastServers)
|
||||
c.domainSearch = ensureNameCap(c.domainSearch, c.limits.MaxDomainSearch)
|
||||
c.ntpNames = ensureNameCap(c.ntpNames, c.limits.MaxNTPServerNames)
|
||||
c.delegatedPrefixes = ensurePrefixCap(c.delegatedPrefixes, c.limits.MaxDelegatedPrefixes)
|
||||
c.duid = AppendDUIDLL(c.duid[:0], cfg.ClientHardwareAddr)
|
||||
c.state = StateInit
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAddrCap returns s truncated to length 0 with capacity at least n,
|
||||
// allocating a new backing array only when the existing one is too small.
|
||||
func ensureAddrCap(s []netip.Addr, n int) []netip.Addr {
|
||||
if cap(s) < n {
|
||||
return make([]netip.Addr, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
func ensureNameCap(s []dns.Name, n int) []dns.Name {
|
||||
if cap(s) < n {
|
||||
return make([]dns.Name, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
func ensurePrefixCap(s []DelegatedPrefix, n int) []DelegatedPrefix {
|
||||
if cap(s) < n {
|
||||
return make([]DelegatedPrefix, 0, n)
|
||||
}
|
||||
return s[:0]
|
||||
}
|
||||
|
||||
// Reset clears the DHCPv6 exchange state and invalidates stack registrations.
|
||||
func (c *Client) Reset() { c.reset() }
|
||||
|
||||
// reset clears exchange state while preserving slice backing arrays and the
|
||||
// connection ID is incremented to invalidate any existing stack registrations.
|
||||
func (c *Client) reset() {
|
||||
*c = Client{
|
||||
connID: c.connID + 1,
|
||||
xid: c.xid,
|
||||
clientMAC: c.clientMAC,
|
||||
iaid: c.iaid,
|
||||
duid: c.duid,
|
||||
serverDUID: c.serverDUID[:0],
|
||||
dns: c.dns[:0],
|
||||
connID: c.connID + 1,
|
||||
xid: c.xid,
|
||||
clientMAC: c.clientMAC,
|
||||
iaid: c.iaid,
|
||||
limits: c.limits,
|
||||
duid: c.duid,
|
||||
serverDUID: c.serverDUID[:0],
|
||||
dns: c.dns[:0],
|
||||
domainSearch: c.domainSearch[:0],
|
||||
ntps: c.ntps[:0],
|
||||
ntpMulticast: c.ntpMulticast[:0],
|
||||
ntpNames: c.ntpNames[:0],
|
||||
delegatedPrefixes: c.delegatedPrefixes[:0],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +244,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
|
||||
frm.SetTransactionID(c.xid)
|
||||
n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
|
||||
numOpts += n
|
||||
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, nil)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...)
|
||||
@@ -130,6 +261,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...)
|
||||
numOpts += n
|
||||
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
|
||||
numOpts += n
|
||||
auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0)
|
||||
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN])
|
||||
numOpts += n
|
||||
@@ -185,6 +318,9 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frm.MsgType() == MsgReconfigure {
|
||||
return c.handleReconfigure(frm)
|
||||
}
|
||||
if frm.TransactionID() != c.xid {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
@@ -213,6 +349,41 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) handleReconfigure(frm Frame) error {
|
||||
if !c.state.HasIP() {
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
var clientOK, serverOK bool
|
||||
var reconf MsgType
|
||||
err := frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
|
||||
switch code {
|
||||
case OptClientID:
|
||||
clientOK = internal.BytesEqual(data, c.duid)
|
||||
case OptServerID:
|
||||
serverOK = len(c.serverDUID) == 0 || internal.BytesEqual(data, c.serverDUID)
|
||||
case OptReconfMsg:
|
||||
if len(data) != 1 {
|
||||
return lneto.ErrInvalidLengthField
|
||||
}
|
||||
reconf = MsgType(data[0])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !clientOK || !serverOK {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
c.reconfigureMsg = reconf
|
||||
c.reconfigureOK = true
|
||||
if reconf != MsgRenew {
|
||||
return lneto.ErrUnsupported
|
||||
}
|
||||
c.state = StateRenewing
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOptions parses all DHCPv6 options in frm and stores relevant values.
|
||||
func (c *Client) setOptions(frm Frame) error {
|
||||
return frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
|
||||
@@ -221,18 +392,121 @@ func (c *Client) setOptions(frm Frame) error {
|
||||
c.serverDUID = append(c.serverDUID[:0], data...)
|
||||
case OptIANA:
|
||||
c.parseIANA(data)
|
||||
case OptIAPD:
|
||||
c.parseIAPD(data)
|
||||
case OptDNSServers:
|
||||
if len(c.dns) > 0 || len(data)%16 != 0 {
|
||||
break // skip if already populated or malformed
|
||||
}
|
||||
for i := 0; i+16 <= len(data); i += 16 {
|
||||
for i := 0; i+16 <= len(data) && len(c.dns) < c.limits.MaxDNSServers; i += 16 {
|
||||
c.dns = append(c.dns, netip.AddrFrom16([16]byte(data[i:i+16])))
|
||||
}
|
||||
case OptDomainList:
|
||||
c.parseDomainSearch(data)
|
||||
case OptNTPServer:
|
||||
c.parseNTPServer(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) parseIAPD(data []byte) {
|
||||
if len(c.delegatedPrefixes) > 0 {
|
||||
return
|
||||
}
|
||||
if len(data) < 12 {
|
||||
return
|
||||
}
|
||||
t1 := binary.BigEndian.Uint32(data[4:8])
|
||||
t2 := binary.BigEndian.Uint32(data[8:12])
|
||||
|
||||
for ptr := 12; ptr+4 <= len(data); {
|
||||
subCode := OptCode(binary.BigEndian.Uint16(data[ptr:]))
|
||||
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
|
||||
if ptr+4+subLen > len(data) {
|
||||
break
|
||||
}
|
||||
if subCode == OptIAPrefix && subLen >= 25 && len(c.delegatedPrefixes) < c.limits.MaxDelegatedPrefixes {
|
||||
sub := data[ptr+4 : ptr+4+subLen]
|
||||
bits := int(sub[8])
|
||||
prefix := netip.PrefixFrom(netip.AddrFrom16([16]byte(sub[9:25])), bits)
|
||||
if prefix.IsValid() {
|
||||
c.delegatedPrefixes = append(c.delegatedPrefixes, DelegatedPrefix{
|
||||
Prefix: prefix.Masked(),
|
||||
PreferredLifetime: binary.BigEndian.Uint32(sub[:4]),
|
||||
ValidLifetime: binary.BigEndian.Uint32(sub[4:8]),
|
||||
})
|
||||
}
|
||||
}
|
||||
ptr += 4 + subLen
|
||||
}
|
||||
if len(c.delegatedPrefixes) > 0 {
|
||||
c.pdT1 = t1
|
||||
c.pdT2 = t2
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) parseNTPServer(data []byte) {
|
||||
if len(c.ntps) > 0 || len(c.ntpMulticast) > 0 || len(c.ntpNames) > 0 {
|
||||
return
|
||||
}
|
||||
for ptr := 0; ptr+4 <= len(data); {
|
||||
subCode := binary.BigEndian.Uint16(data[ptr:])
|
||||
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
|
||||
if ptr+4+subLen > len(data) {
|
||||
break
|
||||
}
|
||||
subData := data[ptr+4 : ptr+4+subLen]
|
||||
switch subCode {
|
||||
case 1:
|
||||
if subLen == 16 && len(c.ntps) < c.limits.MaxNTPServers {
|
||||
c.ntps = append(c.ntps, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
|
||||
}
|
||||
case 2:
|
||||
if subLen == 16 && len(c.ntpMulticast) < c.limits.MaxNTPMulticastServers {
|
||||
c.ntpMulticast = append(c.ntpMulticast, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
|
||||
}
|
||||
case 3:
|
||||
idx := len(c.ntpNames)
|
||||
if idx >= c.limits.MaxNTPServerNames {
|
||||
break // suboption switch: cap reached, drop further names.
|
||||
}
|
||||
if idx < cap(c.ntpNames) {
|
||||
c.ntpNames = c.ntpNames[:idx+1]
|
||||
} else {
|
||||
c.ntpNames = append(c.ntpNames, dns.Name{})
|
||||
}
|
||||
next, err := c.ntpNames[idx].Decode(subData, 0)
|
||||
if err != nil || next != uint16(len(subData)) {
|
||||
c.ntpNames[idx].Reset()
|
||||
c.ntpNames = c.ntpNames[:idx]
|
||||
}
|
||||
}
|
||||
ptr += 4 + subLen
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) parseDomainSearch(data []byte) {
|
||||
if len(c.domainSearch) > 0 {
|
||||
return
|
||||
}
|
||||
for off := uint16(0); off < uint16(len(data)) && len(c.domainSearch) < c.limits.MaxDomainSearch; {
|
||||
idx := len(c.domainSearch)
|
||||
if idx < cap(c.domainSearch) {
|
||||
c.domainSearch = c.domainSearch[:idx+1]
|
||||
} else {
|
||||
c.domainSearch = append(c.domainSearch, dns.Name{})
|
||||
}
|
||||
next, err := c.domainSearch[idx].Decode(data, off)
|
||||
if err != nil || next <= off {
|
||||
c.domainSearch[idx].Reset()
|
||||
c.domainSearch = c.domainSearch[:idx]
|
||||
return
|
||||
}
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
// parseIANA processes the payload of an OptIANA option, extracting the
|
||||
// assigned address and lease timers from any embedded OptIAAddr sub-option.
|
||||
func (c *Client) parseIANA(data []byte) {
|
||||
@@ -273,15 +547,70 @@ func (c *Client) isClosed() bool { return c.state == 0 || c.xid == 0 }
|
||||
// State returns the current client state.
|
||||
func (c *Client) State() ClientState { return c.state }
|
||||
|
||||
// LastReconfigure returns the last accepted Reconfigure message target.
|
||||
func (c *Client) LastReconfigure() (MsgType, bool) { return c.reconfigureMsg, c.reconfigureOK }
|
||||
|
||||
// AssignedAddr returns the IPv6 address assigned by the server and whether it is valid.
|
||||
func (c *Client) AssignedAddr() ([16]byte, bool) { return c.assignedAddr, c.assignedAddrValid }
|
||||
|
||||
// RebindingSeconds returns the IA_NA T2 timer from the server.
|
||||
func (c *Client) RebindingSeconds() uint32 { return c.t2 }
|
||||
|
||||
// RenewalSeconds returns the IA_NA T1 timer from the server.
|
||||
func (c *Client) RenewalSeconds() uint32 { return c.t1 }
|
||||
|
||||
// PreferredLifetimeSeconds returns the preferred lifetime of the assigned address.
|
||||
func (c *Client) PreferredLifetimeSeconds() uint32 { return c.preferredLifetime }
|
||||
|
||||
// ValidLifetimeSeconds returns the valid lifetime of the assigned address.
|
||||
func (c *Client) ValidLifetimeSeconds() uint32 { return c.validLifetime }
|
||||
|
||||
// PrefixDelegationRebindingSeconds returns the IA_PD T2 timer from the server.
|
||||
func (c *Client) PrefixDelegationRebindingSeconds() uint32 { return c.pdT2 }
|
||||
|
||||
// PrefixDelegationRenewalSeconds returns the IA_PD T1 timer from the server.
|
||||
func (c *Client) PrefixDelegationRenewalSeconds() uint32 { return c.pdT1 }
|
||||
|
||||
// AppendDelegatedPrefixes appends delegated IPv6 prefixes received from the server to dst.
|
||||
func (c *Client) AppendDelegatedPrefixes(dst []DelegatedPrefix) []DelegatedPrefix {
|
||||
return append(dst, c.delegatedPrefixes...)
|
||||
}
|
||||
|
||||
// NumDelegatedPrefixes returns the number of delegated IPv6 prefixes received.
|
||||
func (c *Client) NumDelegatedPrefixes() int { return len(c.delegatedPrefixes) }
|
||||
|
||||
// AppendDNSServers appends the DNS server addresses received from the server to dst.
|
||||
func (c *Client) AppendDNSServers(dst []netip.Addr) []netip.Addr { return append(dst, c.dns...) }
|
||||
|
||||
// NumDNSServers returns the number of DNS server addresses received.
|
||||
func (c *Client) NumDNSServers() int { return len(c.dns) }
|
||||
|
||||
// AppendDomainSearch appends the DNS domain search names received from the server to dst.
|
||||
func (c *Client) AppendDomainSearch(dst []dns.Name) []dns.Name { return append(dst, c.domainSearch...) }
|
||||
|
||||
// NumDomainSearch returns the number of DNS domain search names received.
|
||||
func (c *Client) NumDomainSearch() int { return len(c.domainSearch) }
|
||||
|
||||
// AppendNTPServers appends the NTP server addresses received from the server to dst.
|
||||
func (c *Client) AppendNTPServers(dst []netip.Addr) []netip.Addr { return append(dst, c.ntps...) }
|
||||
|
||||
// NumNTPServers returns the number of NTP server addresses received.
|
||||
func (c *Client) NumNTPServers() int { return len(c.ntps) }
|
||||
|
||||
// AppendNTPMulticastServers appends NTP multicast addresses received from the server to dst.
|
||||
func (c *Client) AppendNTPMulticastServers(dst []netip.Addr) []netip.Addr {
|
||||
return append(dst, c.ntpMulticast...)
|
||||
}
|
||||
|
||||
// NumNTPMulticastServers returns the number of NTP multicast addresses received.
|
||||
func (c *Client) NumNTPMulticastServers() int { return len(c.ntpMulticast) }
|
||||
|
||||
// AppendNTPServerNames appends NTP server FQDNs received from the server to dst.
|
||||
func (c *Client) AppendNTPServerNames(dst []dns.Name) []dns.Name { return append(dst, c.ntpNames...) }
|
||||
|
||||
// NumNTPServerNames returns the number of NTP server FQDNs received.
|
||||
func (c *Client) NumNTPServerNames() int { return len(c.ntpNames) }
|
||||
|
||||
// ConnectionID returns a pointer to the client's connection ID.
|
||||
// The value increments on each reset; callers should discard registrations when it changes.
|
||||
// Implements [lneto.StackNode].
|
||||
|
||||
@@ -2,7 +2,10 @@ package dhcpv6
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/dns"
|
||||
)
|
||||
|
||||
// writeOpt6 encodes a single DHCPv6 option into dst using the 4-byte TLV header
|
||||
@@ -124,6 +127,9 @@ func TestClientSolicitRequest(t *testing.T) {
|
||||
if n == 0 {
|
||||
t.Fatal("Encapsulate (Solicit): wrote 0 bytes")
|
||||
}
|
||||
if !frameHasOption(t, buf[:n], OptReconfAccept) {
|
||||
t.Fatal("Solicit missing Reconfigure Accept option")
|
||||
}
|
||||
if cl.State() != StateSoliciting {
|
||||
t.Fatalf("after Solicit: want StateSoliciting, got %v", cl.State())
|
||||
}
|
||||
@@ -164,6 +170,291 @@ func TestClientSolicitRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReconfigureRenew(t *testing.T) {
|
||||
const xid = 0x112233
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cl.State() != StateBound {
|
||||
t.Fatalf("state before reconfigure = %v, want %v", cl.State(), StateBound)
|
||||
}
|
||||
reconf := buildReconfigureFrame(0x445566, serverDUID, cl.duid, MsgRenew)
|
||||
if err := cl.Demux(reconf, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cl.State() != StateRenewing {
|
||||
t.Fatalf("state after reconfigure = %v, want %v", cl.State(), StateRenewing)
|
||||
}
|
||||
msg, ok := cl.LastReconfigure()
|
||||
if !ok || msg != MsgRenew {
|
||||
t.Fatalf("LastReconfigure = %v, %v, want %v, true", msg, ok, MsgRenew)
|
||||
}
|
||||
n, err := cl.Encapsulate(buf[:], -1, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frm, err := NewFrame(buf[:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frm.MsgType() != MsgRenew {
|
||||
t.Fatalf("Encapsulate after Reconfigure type = %v, want %v", frm.MsgType(), MsgRenew)
|
||||
}
|
||||
}
|
||||
|
||||
func buildReconfigureFrame(xid uint32, serverDUID, clientDUID []byte, msg MsgType) []byte {
|
||||
buf := make([]byte, 128)
|
||||
buf[0] = byte(MsgReconfigure)
|
||||
buf[1] = byte(xid >> 16)
|
||||
buf[2] = byte(xid >> 8)
|
||||
buf[3] = byte(xid)
|
||||
n := OptionsOffset
|
||||
n += writeOpt6(buf[n:], OptServerID, serverDUID...)
|
||||
n += writeOpt6(buf[n:], OptClientID, clientDUID...)
|
||||
n += writeOpt6(buf[n:], OptReconfMsg, byte(msg))
|
||||
return buf[:n]
|
||||
}
|
||||
|
||||
func frameHasOption(t testing.TB, b []byte, want OptCode) bool {
|
||||
t.Helper()
|
||||
frm, err := NewFrame(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
if err := frm.ForEachOption(func(_ int, code OptCode, _ []byte) error {
|
||||
if code == want {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func TestClientParsesNTPServerOption(t *testing.T) {
|
||||
const xid = 0x102030
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
ntpAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x7b}
|
||||
ntpMulticast := [16]byte{0xff, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0x01}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendNTPServerOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), ntpAddr, ntpMulticast, "time.example.com")
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var ntps []netip.Addr
|
||||
ntps = cl.AppendNTPServers(ntps)
|
||||
if len(ntps) != 1 || ntps[0] != netip.AddrFrom16(ntpAddr) {
|
||||
t.Fatalf("AppendNTPServers = %v, want %v", ntps, netip.AddrFrom16(ntpAddr))
|
||||
}
|
||||
if n := cl.NumNTPServers(); n != 1 {
|
||||
t.Fatalf("NumNTPServers = %d, want 1", n)
|
||||
}
|
||||
var multicasts []netip.Addr
|
||||
multicasts = cl.AppendNTPMulticastServers(multicasts)
|
||||
if len(multicasts) != 1 || multicasts[0] != netip.AddrFrom16(ntpMulticast) {
|
||||
t.Fatalf("AppendNTPMulticastServers = %v, want %v", multicasts, netip.AddrFrom16(ntpMulticast))
|
||||
}
|
||||
if n := cl.NumNTPMulticastServers(); n != 1 {
|
||||
t.Fatalf("NumNTPMulticastServers = %d, want 1", n)
|
||||
}
|
||||
var names []dns.Name
|
||||
names = cl.AppendNTPServerNames(names)
|
||||
if len(names) != 1 || !names[0].EqualString("time.example.com") {
|
||||
t.Fatalf("AppendNTPServerNames = %v, want time.example.com", names)
|
||||
}
|
||||
if n := cl.NumNTPServerNames(); n != 1 {
|
||||
t.Fatalf("NumNTPServerNames = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientParsesDomainSearchOption(t *testing.T) {
|
||||
const xid = 0x203040
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendDomainSearchOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), "example.com", "corp.example.com")
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var domains []dns.Name
|
||||
domains = cl.AppendDomainSearch(domains)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("AppendDomainSearch len = %d, want 2", len(domains))
|
||||
}
|
||||
if !domains[0].EqualString("example.com") || !domains[1].EqualString("corp.example.com") {
|
||||
t.Fatalf("AppendDomainSearch = %q, %q", domains[0].String(), domains[1].String())
|
||||
}
|
||||
if n := cl.NumDomainSearch(); n != 2 {
|
||||
t.Fatalf("NumDomainSearch = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientParsesDelegatedPrefix(t *testing.T) {
|
||||
const xid = 0x304050
|
||||
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
delegated := netip.MustParsePrefix("2001:db8:abcd::/48")
|
||||
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
|
||||
if err := cl.Demux(advFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
|
||||
replyFrame := appendIAPDOption(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), iaid, delegated)
|
||||
if err := cl.Demux(replyFrame, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
var prefixes []DelegatedPrefix
|
||||
prefixes = cl.AppendDelegatedPrefixes(prefixes)
|
||||
if len(prefixes) != 1 {
|
||||
t.Fatalf("AppendDelegatedPrefixes len = %d, want 1", len(prefixes))
|
||||
}
|
||||
if prefixes[0].Prefix != delegated || prefixes[0].PreferredLifetime != 1800 || prefixes[0].ValidLifetime != 3600 {
|
||||
t.Fatalf("delegated prefix = %+v, want %s preferred=1800 valid=3600", prefixes[0], delegated)
|
||||
}
|
||||
if n := cl.NumDelegatedPrefixes(); n != 1 {
|
||||
t.Fatalf("NumDelegatedPrefixes = %d, want 1", n)
|
||||
}
|
||||
if cl.PrefixDelegationRenewalSeconds() != 900 || cl.PrefixDelegationRebindingSeconds() != 1800 {
|
||||
t.Fatalf("IA_PD timers = renewal:%d rebind:%d, want 900/1800", cl.PrefixDelegationRenewalSeconds(), cl.PrefixDelegationRebindingSeconds())
|
||||
}
|
||||
}
|
||||
|
||||
func appendNTPServerOption(t testing.TB, frame []byte, addr, multicast [16]byte, fqdn string) []byte {
|
||||
t.Helper()
|
||||
var ntpPayload []byte
|
||||
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 1, addr)
|
||||
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 2, multicast)
|
||||
name, err := dns.NewName(fqdn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameStart := len(ntpPayload) + 4
|
||||
ntpPayload = append(ntpPayload, 0, 3, 0, 0)
|
||||
ntpPayload, err = name.AppendTo(ntpPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.BigEndian.PutUint16(ntpPayload[nameStart-2:nameStart], uint16(len(ntpPayload)-nameStart))
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(ntpPayload))...)
|
||||
writeOpt6(buf[len(frame):], OptNTPServer, ntpPayload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func appendNTPServerAddrSuboption(dst []byte, code uint16, addr [16]byte) []byte {
|
||||
start := len(dst)
|
||||
dst = append(dst, 0, 0, 0, 16)
|
||||
binary.BigEndian.PutUint16(dst[start:start+2], code)
|
||||
return append(dst, addr[:]...)
|
||||
}
|
||||
|
||||
func appendDomainSearchOption(t testing.TB, frame []byte, domains ...string) []byte {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
for _, domain := range domains {
|
||||
name, err := dns.NewName(domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err = name.AppendTo(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptDomainList, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func appendIAPDOption(frame []byte, iaid [4]byte, prefix netip.Prefix) []byte {
|
||||
var iaPrefix [29]byte
|
||||
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
|
||||
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
|
||||
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
|
||||
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
|
||||
iaPrefix[12] = byte(prefix.Bits())
|
||||
prefixAddr := prefix.Addr().As16()
|
||||
copy(iaPrefix[13:], prefixAddr[:])
|
||||
var iapd [41]byte
|
||||
copy(iapd[:4], iaid[:])
|
||||
binary.BigEndian.PutUint32(iapd[4:8], 900)
|
||||
binary.BigEndian.PutUint32(iapd[8:12], 1800)
|
||||
copy(iapd[12:], iaPrefix[:])
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(iapd))...)
|
||||
writeOpt6(buf[len(frame):], OptIAPD, iapd[:]...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// TestClientEncapsulateSolicit verifies that the first Encapsulate call
|
||||
// writes a Solicit message with at least OptClientID and OptIANA.
|
||||
func TestClientEncapsulateSolicit(t *testing.T) {
|
||||
@@ -242,3 +533,188 @@ func TestClientDoubleTapEncapsulate(t *testing.T) {
|
||||
t.Errorf("second Encapsulate: want 0 bytes (idempotent), got %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// driveToRequesting advances a fresh client through Solicit→Advertise→Request so
|
||||
// the next Demux of a Reply runs the option-parsing path.
|
||||
func driveToRequesting(t testing.TB, cl *Client, xid uint32, serverDUID []byte, iaid [4]byte, addr [16]byte) {
|
||||
t.Helper()
|
||||
var buf [1024]byte
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Solicit):", err)
|
||||
}
|
||||
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, addr), 0); err != nil {
|
||||
t.Fatal("Demux (Advertise):", err)
|
||||
}
|
||||
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
|
||||
t.Fatal("Encapsulate (Request):", err)
|
||||
}
|
||||
}
|
||||
|
||||
// floodDNSServersOption appends an OptDNSServers carrying n distinct addresses.
|
||||
func floodDNSServersOption(frame []byte, n int) []byte {
|
||||
payload := make([]byte, 0, n*16)
|
||||
for i := range n {
|
||||
var a [16]byte
|
||||
a[0], a[1], a[15] = 0x20, 0x01, byte(i+1)
|
||||
payload = append(payload, a[:]...)
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptDNSServers, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// floodIAPDOption appends an OptIAPD carrying n OptIAPrefix sub-options.
|
||||
func floodIAPDOption(frame []byte, iaid [4]byte, n int) []byte {
|
||||
payload := make([]byte, 12)
|
||||
copy(payload[:4], iaid[:])
|
||||
binary.BigEndian.PutUint32(payload[4:8], 900)
|
||||
binary.BigEndian.PutUint32(payload[8:12], 1800)
|
||||
for i := range n {
|
||||
var iaPrefix [29]byte
|
||||
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
|
||||
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
|
||||
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
|
||||
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
|
||||
iaPrefix[12] = 48
|
||||
iaPrefix[13], iaPrefix[14], iaPrefix[15] = 0x20, 0x01, byte(i+1)
|
||||
payload = append(payload, iaPrefix[:]...)
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptIAPD, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// floodNTPOption appends an OptNTPServer carrying nAddr unicast (suboption 1),
|
||||
// nMulticast (suboption 2) and nNames FQDN (suboption 3) entries.
|
||||
func floodNTPOption(t testing.TB, frame []byte, nAddr, nMulticast, nNames int) []byte {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
for i := range nAddr {
|
||||
var a [16]byte
|
||||
a[0], a[15] = 0x20, byte(i+1)
|
||||
payload = appendNTPServerAddrSuboption(payload, 1, a)
|
||||
}
|
||||
for i := range nMulticast {
|
||||
var a [16]byte
|
||||
a[0], a[1], a[15] = 0xff, 0x05, byte(i+1)
|
||||
payload = appendNTPServerAddrSuboption(payload, 2, a)
|
||||
}
|
||||
name, err := dns.NewName("ntp.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range nNames {
|
||||
start := len(payload) + 4
|
||||
payload = append(payload, 0, 3, 0, 0)
|
||||
payload, err = name.AppendTo(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.BigEndian.PutUint16(payload[start-2:start], uint16(len(payload)-start))
|
||||
}
|
||||
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
|
||||
writeOpt6(buf[len(frame):], OptNTPServer, payload...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func repeatStrings(s string, n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestClientLimitsCapServerData verifies that the client stores no more than
|
||||
// the configured number of each repeated option, so a server flooding a Reply
|
||||
// cannot drive unbounded allocation (the OOM concern raised in the PR review).
|
||||
func TestClientLimitsCapServerData(t *testing.T) {
|
||||
const xid = 0x515151
|
||||
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
|
||||
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
limits := Limits{
|
||||
MaxDNSServers: 2, MaxDomainSearch: 2, MaxNTPServers: 2,
|
||||
MaxNTPMulticastServers: 1, MaxNTPServerNames: 2, MaxDelegatedPrefixes: 2,
|
||||
}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac, Limits: limits}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
driveToRequesting(t, &cl, xid, serverDUID, iaid, addr)
|
||||
|
||||
// A Reply far exceeding every configured cap.
|
||||
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
|
||||
reply = floodDNSServersOption(reply, 8)
|
||||
reply = floodIAPDOption(reply, iaid, 8)
|
||||
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", 8)...)
|
||||
reply = floodNTPOption(t, reply, 8, 8, 8)
|
||||
if err := cl.Demux(reply, 0); err != nil {
|
||||
t.Fatal("Demux (Reply):", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"DNS servers", cl.NumDNSServers(), limits.MaxDNSServers},
|
||||
{"domain search", cl.NumDomainSearch(), limits.MaxDomainSearch},
|
||||
{"NTP servers", cl.NumNTPServers(), limits.MaxNTPServers},
|
||||
{"NTP multicast", cl.NumNTPMulticastServers(), limits.MaxNTPMulticastServers},
|
||||
{"NTP names", cl.NumNTPServerNames(), limits.MaxNTPServerNames},
|
||||
{"delegated prefixes", cl.NumDelegatedPrefixes(), limits.MaxDelegatedPrefixes},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s stored = %d, want capped at %d", tc.name, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientParseNoAllocs verifies that once BeginRequest has sized the option
|
||||
// backing arrays, parsing a server message performs no further allocation: the
|
||||
// bounded slices are reused, keeping the network-facing path off the heap.
|
||||
func TestClientParseNoAllocs(t *testing.T) {
|
||||
const xid = 0x123456
|
||||
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
|
||||
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
|
||||
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
|
||||
|
||||
var cl Client
|
||||
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac}); err != nil {
|
||||
t.Fatal("BeginRequest:", err)
|
||||
}
|
||||
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
|
||||
reply = floodDNSServersOption(reply, cl.limits.MaxDNSServers)
|
||||
reply = floodIAPDOption(reply, iaid, cl.limits.MaxDelegatedPrefixes)
|
||||
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", cl.limits.MaxDomainSearch)...)
|
||||
reply = floodNTPOption(t, reply, cl.limits.MaxNTPServers, cl.limits.MaxNTPMulticastServers, cl.limits.MaxNTPServerNames)
|
||||
frm, err := NewFrame(reply)
|
||||
if err != nil {
|
||||
t.Fatal("NewFrame:", err)
|
||||
}
|
||||
|
||||
clearStores := func() {
|
||||
cl.serverDUID = cl.serverDUID[:0]
|
||||
cl.dns = cl.dns[:0]
|
||||
cl.domainSearch = cl.domainSearch[:0]
|
||||
cl.ntps = cl.ntps[:0]
|
||||
cl.ntpMulticast = cl.ntpMulticast[:0]
|
||||
cl.ntpNames = cl.ntpNames[:0]
|
||||
cl.delegatedPrefixes = cl.delegatedPrefixes[:0]
|
||||
}
|
||||
clearStores()
|
||||
if err := cl.setOptions(frm); err != nil { // warm up the dns.Name backing arrays.
|
||||
t.Fatal("setOptions:", err)
|
||||
}
|
||||
allocs := testing.AllocsPerRun(50, func() {
|
||||
clearStores()
|
||||
_ = cl.setOptions(frm)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("setOptions must not allocate after BeginRequest sizing, got %v allocs/op", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-14
@@ -28,11 +28,11 @@ const _MsgType_name = "solicitadvertiserequestconfirmrenewrebindreplyreleasedecl
|
||||
var _MsgType_index = [...]uint8{0, 7, 16, 23, 30, 35, 41, 46, 53, 60, 71, 85, 95, 105}
|
||||
|
||||
func (i MsgType) String() string {
|
||||
i -= 1
|
||||
if i >= MsgType(len(_MsgType_index)-1) {
|
||||
return "MsgType(" + strconv.FormatInt(int64(i+1), 10) + ")"
|
||||
idx := int(i) - 1
|
||||
if i < 1 || idx >= len(_MsgType_index)-1 {
|
||||
return "MsgType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _MsgType_name[_MsgType_index[i]:_MsgType_index[i+1]]
|
||||
return _MsgType_name[_MsgType_index[idx]:_MsgType_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -51,11 +51,11 @@ const _ClientState_name = "initsolicitingrequestingboundrenewingrebinding"
|
||||
var _ClientState_index = [...]uint8{0, 4, 14, 24, 29, 37, 46}
|
||||
|
||||
func (i ClientState) String() string {
|
||||
i -= 1
|
||||
if i >= ClientState(len(_ClientState_index)-1) {
|
||||
return "ClientState(" + strconv.FormatInt(int64(i+1), 10) + ")"
|
||||
idx := int(i) - 1
|
||||
if i < 1 || idx >= len(_ClientState_index)-1 {
|
||||
return "ClientState(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _ClientState_name[_ClientState_index[i]:_ClientState_index[i+1]]
|
||||
return _ClientState_name[_ClientState_index[idx]:_ClientState_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -134,10 +134,11 @@ const _StatusCode_name = "successunspec-failno-addrs-availno-bindingnot-on-linku
|
||||
var _StatusCode_index = [...]uint8{0, 7, 18, 32, 42, 53, 66}
|
||||
|
||||
func (i StatusCode) String() string {
|
||||
if i >= StatusCode(len(_StatusCode_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_StatusCode_index)-1 {
|
||||
return "StatusCode(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _StatusCode_name[_StatusCode_index[i]:_StatusCode_index[i+1]]
|
||||
return _StatusCode_name[_StatusCode_index[idx]:_StatusCode_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -153,9 +154,9 @@ const _DUIDType_name = "duid-lltduid-enduid-ll"
|
||||
var _DUIDType_index = [...]uint8{0, 8, 15, 22}
|
||||
|
||||
func (i DUIDType) String() string {
|
||||
i -= 1
|
||||
if i >= DUIDType(len(_DUIDType_index)-1) {
|
||||
return "DUIDType(" + strconv.FormatInt(int64(i+1), 10) + ")"
|
||||
idx := int(i) - 1
|
||||
if i < 1 || idx >= len(_DUIDType_index)-1 {
|
||||
return "DUIDType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _DUIDType_name[_DUIDType_index[i]:_DUIDType_index[i+1]]
|
||||
return _DUIDType_name[_DUIDType_index[idx]:_DUIDType_index[idx+1]]
|
||||
}
|
||||
|
||||
+6
-4
@@ -119,10 +119,11 @@ const _RCode_name = "successformat errorserver failurename errornot implementedr
|
||||
var _RCode_index = [...]uint8{0, 7, 19, 33, 43, 58, 65}
|
||||
|
||||
func (i RCode) String() string {
|
||||
if i >= RCode(len(_RCode_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_RCode_index)-1 {
|
||||
return "RCode(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _RCode_name[_RCode_index[i]:_RCode_index[i+1]]
|
||||
return _RCode_name[_RCode_index[idx]:_RCode_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -138,8 +139,9 @@ const _OpCode_name = "Standard queryInverse queryServer status request"
|
||||
var _OpCode_index = [...]uint8{0, 14, 27, 48}
|
||||
|
||||
func (i OpCode) String() string {
|
||||
if i >= OpCode(len(_OpCode_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_OpCode_index)-1 {
|
||||
return "OpCode(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _OpCode_name[_OpCode_index[i]:_OpCode_index[i+1]]
|
||||
return _OpCode_name[_OpCode_index[idx]:_OpCode_index[idx+1]]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
ErrAlreadyRegistered // protocol already registered
|
||||
ErrTruncatedFrame // truncated frame
|
||||
ErrMissingHALConfig // missing HAL configuration
|
||||
ErrBadState // operation invalid in current state
|
||||
// Below are potentially good future error additions
|
||||
// based on one or two encountered use cases, example use case included.
|
||||
/*
|
||||
|
||||
@@ -25,6 +25,15 @@ const (
|
||||
MinimumMTU = MinimumFrameLength - sizeHeaderNoVLAN
|
||||
)
|
||||
|
||||
// String returns the colon-separated hexadecimal text representation of a hardware address.
|
||||
func String(hwAddr [6]byte) string {
|
||||
// See net/netip's (Addr).string4 pattern.
|
||||
var buf [maxAddrStringLen]byte
|
||||
return string(AppendAddr(buf[:0], hwAddr))
|
||||
}
|
||||
|
||||
const maxAddrStringLen = len("ff:ff:ff:ff:ff:ff")
|
||||
|
||||
// AppendAddr appends the text representation of the hardware address to the destination buffer.
|
||||
func AppendAddr(dst []byte, hwAddr [6]byte) []byte {
|
||||
for i, b := range hwAddr {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ethernet
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr [6]byte
|
||||
want string
|
||||
}{
|
||||
{addr: [6]byte{}, want: "00:00:00:00:00:00"},
|
||||
{addr: [6]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, want: "ff:ff:ff:ff:ff:ff"},
|
||||
{addr: [6]byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01}, want: "de:ad:be:ef:00:01"},
|
||||
{addr: [6]byte{0x01, 0x00, 0x5e, 0x7f, 0x00, 0x0f}, want: "01:00:5e:7f:00:0f"},
|
||||
} {
|
||||
got := String(tc.addr)
|
||||
if got != tc.want {
|
||||
t.Errorf("String(%v): got %q, want %q", tc.addr, got, tc.want)
|
||||
}
|
||||
if want := net.HardwareAddr(tc.addr[:]).String(); got != want {
|
||||
t.Errorf("String(%v) disagrees with net: got %q, want %q", tc.addr, got, want)
|
||||
}
|
||||
if got := string(AppendAddr(nil, tc.addr)); got != tc.want {
|
||||
t.Errorf("AppendAddr(%v): got %q, want %q", tc.addr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestString_singleAlloc(t *testing.T) {
|
||||
addr := [6]byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01}
|
||||
var sink string
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
sink = String(addr)
|
||||
})
|
||||
_ = sink
|
||||
// Only the returned string allocates; the scratch buffer must stay on the stack.
|
||||
if allocs != 1 {
|
||||
t.Errorf("expected 1 alloc, got %v", allocs)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,17 @@ func main() {
|
||||
|
||||
var runner netdev.Runner[espradio.STAConfig]
|
||||
go func() {
|
||||
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
|
||||
// EthPoll drains the C ring buffer and delivers frames through the
|
||||
// receive handler, so the device is async but still needs the pump.
|
||||
err := runner.Configure(netdev.RunnerConfig[espradio.STAConfig]{
|
||||
Buffers: iface.RunnerBuffers(2),
|
||||
Backoff: backoff,
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
failIfErr("runnerconfig", err)
|
||||
}
|
||||
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
|
||||
failIfErr("runner", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -2,10 +2,12 @@ module piconetdev
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require github.com/soypat/cyw43439 v0.1.1
|
||||
require (
|
||||
github.com/soypat/cyw43439 v0.1.1
|
||||
github.com/soypat/lneto v0.1.1-0.20260425023453-aa77403a2b32
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/soypat/lneto v0.1.0 // indirect
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
|
||||
github.com/tinygo-org/pio v0.2.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
|
||||
@@ -13,4 +15,7 @@ require (
|
||||
|
||||
// This is an example taken grom github.com/soypat/lneto
|
||||
// Remove this replace directive when using as own program.
|
||||
replace github.com/soypat/lneto => ../../../.
|
||||
replace github.com/soypat/lneto => ../../../.
|
||||
|
||||
// Local cyw43439 with the poll-based EthPoll API.
|
||||
replace github.com/soypat/cyw43439 => ../../../../cyw43439
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE=
|
||||
github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc=
|
||||
github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I=
|
||||
github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
|
||||
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
|
||||
|
||||
@@ -70,14 +70,25 @@ func main() {
|
||||
failIfErr("init iface", err)
|
||||
|
||||
go func() {
|
||||
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
|
||||
err := runner.Configure(netdev.RunnerConfig[ConnectParams]{
|
||||
Buffers: iface.RunnerBuffers(1),
|
||||
Backoff: backoff,
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
failIfErr("runnerconfig", err)
|
||||
}
|
||||
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
|
||||
failIfErr("runner", err)
|
||||
}
|
||||
}()
|
||||
assigned, gatewayRt, subnetBits, err := stack.EnableDHCP(context.Background(), true, netip.Addr{})
|
||||
failIfErr("enable dhcp", err)
|
||||
println("assigned=", assigned.String(), "gateway=", gatewayRt.String(), "subnet", subnetBits)
|
||||
select {}
|
||||
for {
|
||||
runner.PrintDebug()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// compile-time guarantee of interface implementation.
|
||||
@@ -142,23 +153,24 @@ func (d *Netdev) SendOffsetEthFrame(offsetTxEthFrame []byte) error {
|
||||
}
|
||||
|
||||
// SetRecvHandler implements [netdev.DevEthernet].
|
||||
//
|
||||
// The cyw43439 delivers received Ethernet frames through this callback whenever
|
||||
// the bus is serviced (including ioctl/control transactions outside EthPoll), so
|
||||
// the runner must drive it in async mode. EthPoll is still used to pump the device.
|
||||
func (d *Netdev) SetEthRecvHandler(handler func(rxEthframe []byte)) {
|
||||
d.dev.RecvEthHandle(func(pkt []byte) error {
|
||||
handler(pkt)
|
||||
return nil
|
||||
})
|
||||
d.dev.RecvEthHandle(handler)
|
||||
}
|
||||
|
||||
// EthPoll implements [netdev.DevEthernet].
|
||||
func (d *Netdev) EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) {
|
||||
_, err = d.dev.PollOne()
|
||||
return 0, 0, err
|
||||
return d.dev.EthPoll(buf)
|
||||
}
|
||||
|
||||
// MaxFrameSizeAndOffset implements [netdev.DevEthernet].
|
||||
func (d *Netdev) MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) {
|
||||
return cyw43439.MaxFrameSize, 0
|
||||
return 2048, 0
|
||||
}
|
||||
|
||||
func failIfErr(msg string, err error) {
|
||||
if err != nil {
|
||||
fail(msg, err)
|
||||
|
||||
@@ -274,7 +274,7 @@ func handleConnNet(conn net.Conn) error {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
var hdr httpraw.Header
|
||||
var hdr httpraw.HeaderV1
|
||||
needMore := true
|
||||
for needMore {
|
||||
_, err := hdr.ReadFromLimited(conn, 1024)
|
||||
@@ -288,10 +288,10 @@ func handleConnNet(conn net.Conn) error {
|
||||
}
|
||||
}
|
||||
method := string(hdr.Method())
|
||||
uri := string(hdr.RequestURI())
|
||||
uri := string(hdr.RequestTarget())
|
||||
fmt.Printf("< %s %s\n", method, uri)
|
||||
|
||||
var resp httpraw.Header
|
||||
var resp httpraw.HeaderV1
|
||||
resp.SetProtocol("HTTP/1.1")
|
||||
resp.SetStatus("200", "OK")
|
||||
resp.Set("Content-Type", "text/html")
|
||||
@@ -368,9 +368,9 @@ func mockClient(stack *xnet.StackAsync, port uint16, subnet netip.Prefix) {
|
||||
panic("mock client deadline exceeded to establish")
|
||||
}
|
||||
|
||||
var hdr httpraw.Header
|
||||
var hdr httpraw.HeaderV1
|
||||
hdr.SetMethod("GET")
|
||||
hdr.SetRequestURI("/")
|
||||
hdr.SetRequestTarget("/")
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
hdr.Set("Host", string(ipv4.AppendFormatAddr(nil, stack.Addr4())))
|
||||
hdr.Set("User-Agent", "lneto-mock")
|
||||
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env bash
|
||||
# fuzz.sh points ffuf at an already running main-httplinux server. Start it
|
||||
# yourself first, in another terminal, so its log and any crash stay visible:
|
||||
#
|
||||
# go run ./examples/http-linux -port 8080
|
||||
# ./examples/http-linux/fuzz.sh # all cases
|
||||
# ./examples/http-linux/fuzz.sh paths cookie # named cases only
|
||||
# URL=http://localhost:9000 ./examples/http-linux/fuzz.sh
|
||||
#
|
||||
# Install ffuf with: go install github.com/ffuf/ffuf/v2@latest
|
||||
#
|
||||
# Every case ends by reporting the server is still up: a case that "finds
|
||||
# nothing" because the process died is the failure this is looking for.
|
||||
set -u
|
||||
|
||||
URL="${URL:-http://localhost:8080}"
|
||||
# Matched to the server's FixedNumGoroutines: in worker mode the router owns one
|
||||
# exchange per goroutine and refuses a connection outright when none is free, so
|
||||
# that count is what bounds concurrency. Going above it is correct backpressure,
|
||||
# but it reaches ffuf as a connection error and hides the response a case was
|
||||
# looking for. Raise it to exercise the drop path.
|
||||
THREADS="${THREADS:-4}"
|
||||
|
||||
WORDDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORDDIR"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wordlists. Kept here rather than pulled from SecLists so a run is repeatable
|
||||
# and every entry is aimed at the parser: percent escapes, separators the
|
||||
# grammar gives meaning to, and lengths that cross the server's fixed buffers.
|
||||
# ---------------------------------------------------------------------------
|
||||
cat >"$WORDDIR/paths.txt" <<'EOF'
|
||||
admin
|
||||
login
|
||||
search
|
||||
health
|
||||
echo
|
||||
upload
|
||||
users
|
||||
files
|
||||
users/alice
|
||||
users/bob
|
||||
users/carol
|
||||
users/mallory
|
||||
users/al%69ce
|
||||
users/%zz
|
||||
users/%2e%2e%2f
|
||||
users/alice/extra
|
||||
files/
|
||||
files/readme.txt
|
||||
files/logo.png
|
||||
files/notes.md
|
||||
files/a/b/c
|
||||
files/%2e%2e/%2e%2e/etc/passwd
|
||||
EOF
|
||||
|
||||
cat >"$WORDDIR/queries.txt" <<'EOF'
|
||||
go
|
||||
go+lang
|
||||
go%20lang
|
||||
%21%40%23
|
||||
%zz
|
||||
%
|
||||
%2
|
||||
a=b
|
||||
a&b
|
||||
a;b
|
||||
""
|
||||
EOF
|
||||
|
||||
cat >"$WORDDIR/passwords.txt" <<'EOF'
|
||||
hunter2
|
||||
password
|
||||
admin
|
||||
letmein
|
||||
hunter2%00
|
||||
hunter2+
|
||||
hun%74er2
|
||||
EOF
|
||||
|
||||
cat >"$WORDDIR/tokens.txt" <<'EOF'
|
||||
s3cr3t-session-token
|
||||
admin
|
||||
""
|
||||
"s3cr3t-session-token"
|
||||
s3cr3t-session-token; debug
|
||||
s3cr3t-session-token;debug
|
||||
=====
|
||||
;;;;;
|
||||
EOF
|
||||
|
||||
cat >"$WORDDIR/headers.txt" <<'EOF'
|
||||
plain
|
||||
with spaces
|
||||
%00%01%02
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
EOF
|
||||
|
||||
cat >"$WORDDIR/names.txt" <<'EOF'
|
||||
a.bin
|
||||
report.pdf
|
||||
../escape.txt
|
||||
%2e%2e%2fescape.txt
|
||||
EOF
|
||||
|
||||
# grow prints a line of n 'A's, for the cases that walk a value past a buffer.
|
||||
grow() { printf 'A%.0s' $(seq "$1"); printf '\n'; }
|
||||
{ for n in 8 64 512 1024 2048 4096 8192; do grow "$n"; done; } >"$WORDDIR/long.txt"
|
||||
|
||||
alive() {
|
||||
if curl -s -o /dev/null --max-time 5 "$URL/health"; then
|
||||
printf ' server alive\n\n'
|
||||
else
|
||||
printf ' *** SERVER DOWN after this case ***\n\n'
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case_header() { printf '=== %s: %s\n' "$1" "$2"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cases. Each one drives a different part of the request through the parser.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# paths walks the mux: literal patterns, the "{id}" single-segment wildcard and
|
||||
# the "{path...}" wildcard that swallows slashes. -mc all because a 404 from an
|
||||
# unregistered path is a correct answer worth seeing next to the 200s.
|
||||
fuzz_paths() {
|
||||
case_header paths "mux patterns, wildcards and percent escapes in the path"
|
||||
ffuf -u "$URL/FUZZ" -w "$WORDDIR/paths.txt" -t "$THREADS" -s -timeout 5 -mc all -fc 404
|
||||
alive
|
||||
}
|
||||
|
||||
# recursion follows the "{path...}" wildcard down, which is the pattern a
|
||||
# directory scanner exercises hardest.
|
||||
fuzz_recursion() {
|
||||
case_header recursion "\"{path...}\" wildcard walked recursively"
|
||||
ffuf -u "$URL/files/FUZZ" -w "$WORDDIR/paths.txt" -t "$THREADS" -s -timeout 5 \
|
||||
-recursion -recursion-depth 2 -recursion-strategy greedy -mc all -fc 404
|
||||
alive
|
||||
}
|
||||
|
||||
# longpath pushes the request-target past RequestHeaderBufferSize. The server
|
||||
# should answer 431 or drop the connection, never serve a mangled path.
|
||||
fuzz_longpath() {
|
||||
case_header longpath "request-target grown past the request header buffer"
|
||||
ffuf -u "$URL/FUZZ" -w "$WORDDIR/long.txt" -t 4 -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# query drives RequestQueryValue and the percent decoder, including escapes that
|
||||
# do not decode, which must come back 400 and not half decoded.
|
||||
fuzz_query() {
|
||||
case_header query "query string values, valid and malformed escapes"
|
||||
ffuf -u "$URL/search?q=FUZZ" -w "$WORDDIR/queries.txt" -t "$THREADS" -s -timeout 5 -mc all
|
||||
ffuf -u "$URL/search?q=go&limit=FUZZ" -w "$WORDDIR/queries.txt" -t "$THREADS" -s -timeout 5 -mc all
|
||||
case_header query "query value grown past the request header buffer"
|
||||
ffuf -u "$URL/search?q=FUZZ" -w "$WORDDIR/long.txt" -t 4 -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# form posts "application/x-www-form-urlencoded" bodies, the case the credential
|
||||
# check answers 200 for and everything else 401.
|
||||
fuzz_form() {
|
||||
case_header form "urlencoded body pairs; 200 is the credential that works"
|
||||
ffuf -u "$URL/login" -X POST -w "$WORDDIR/passwords.txt" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-d 'user=admin&pass=FUZZ' -t "$THREADS" -s -timeout 5 -mc all -fc 401
|
||||
case_header form "body grown past the form buffer, which may not grow"
|
||||
ffuf -u "$URL/login" -X POST -w "$WORDDIR/long.txt" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-d 'user=admin&pass=FUZZ' -t 4 -s -timeout 5 -mc all
|
||||
case_header form "pair count driven past the form's fixed pair table"
|
||||
ffuf -u "$URL/login?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10&k=11&l=12&m=13&n=14&o=15&p=16&q=17" \
|
||||
-X POST -w "$WORDDIR/passwords.txt" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-d 'user=admin&pass=FUZZ' -t "$THREADS" -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# cookie drives the Cookie header parser: quoting, valueless attributes and the
|
||||
# separators the grammar splits on.
|
||||
fuzz_cookie() {
|
||||
case_header cookie "Cookie header values; 200 is the session that works"
|
||||
ffuf -u "$URL/admin" -w "$WORDDIR/tokens.txt" -b 'session=FUZZ' \
|
||||
-t "$THREADS" -s -timeout 5 -mc all -fc 403
|
||||
case_header cookie "cookie grown past the cookie buffer"
|
||||
ffuf -u "$URL/admin" -w "$WORDDIR/long.txt" -b 'session=FUZZ' -t 4 -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# headers fuzzes a header field value and the field count, /echo handing back
|
||||
# the header block as the parser stored it.
|
||||
fuzz_headers() {
|
||||
case_header headers "header field values echoed back through the parser"
|
||||
ffuf -u "$URL/echo" -w "$WORDDIR/headers.txt" -H 'X-Fuzz: FUZZ' \
|
||||
-t "$THREADS" -s -timeout 5 -mc all
|
||||
case_header headers "header value grown past the request header buffer"
|
||||
ffuf -u "$URL/echo" -w "$WORDDIR/long.txt" -H 'X-Fuzz: FUZZ' -t 4 -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# multipart fuzzes the part header block: the filename parameter picks whether a
|
||||
# part is streamed to a sink or discarded.
|
||||
fuzz_multipart() {
|
||||
case_header multipart "multipart part headers and filenames"
|
||||
ffuf -u "$URL/upload" -X POST -w "$WORDDIR/names.txt" \
|
||||
-H 'Content-Type: multipart/form-data; boundary=X' \
|
||||
-d $'--X\r\nContent-Disposition: form-data; name="f"; filename="FUZZ"\r\n\r\ndata\r\n--X--\r\n' \
|
||||
-t "$THREADS" -s -timeout 5 -mc all
|
||||
case_header multipart "part header grown past the multipart buffer, expect 413"
|
||||
ffuf -u "$URL/upload" -X POST -w "$WORDDIR/long.txt" \
|
||||
-H 'Content-Type: multipart/form-data; boundary=X' \
|
||||
-d $'--X\r\nContent-Disposition: form-data; name="f"; filename="FUZZ"\r\n\r\ndata\r\n--X--\r\n' \
|
||||
-t 4 -s -timeout 5 -mc all
|
||||
alive
|
||||
}
|
||||
|
||||
# methods sends a method per registration and a few the server never names.
|
||||
# "/echo" is registered without one, so any method reaches it; "/login" is
|
||||
# POST only and everything else must 404 there.
|
||||
fuzz_methods() {
|
||||
case_header methods "registered, unregistered and extension methods"
|
||||
printf 'GET\nPOST\nPUT\nDELETE\nPATCH\nHEAD\nOPTIONS\nTRACE\nPROPFIND\nBREW\n' >"$WORDDIR/methods.txt"
|
||||
ffuf -u "$URL/echo" -w "$WORDDIR/methods.txt" -X FUZZ -t "$THREADS" -s -timeout 5 -mc all
|
||||
ffuf -u "$URL/login" -w "$WORDDIR/methods.txt" -X FUZZ -t "$THREADS" -s -timeout 5 -mc all -fc 404
|
||||
alive
|
||||
}
|
||||
|
||||
# clusterbomb crosses a path wordlist with a query wordlist, so the two parsers
|
||||
# are driven by unrelated inputs in the same request.
|
||||
fuzz_clusterbomb() {
|
||||
case_header clusterbomb "path and query fuzzed together, every combination"
|
||||
ffuf -u "$URL/PATH?q=QUERY" -mode clusterbomb \
|
||||
-w "$WORDDIR/paths.txt:PATH" -w "$WORDDIR/queries.txt:QUERY" \
|
||||
-t "$THREADS" -s -timeout 5 -mc all -fc 404
|
||||
alive
|
||||
}
|
||||
|
||||
ALL=(paths recursion longpath query form cookie headers multipart methods clusterbomb)
|
||||
|
||||
main() {
|
||||
command -v ffuf >/dev/null || {
|
||||
echo "ffuf not found: go install github.com/ffuf/ffuf/v2@latest" >&2
|
||||
exit 1
|
||||
}
|
||||
curl -s -o /dev/null --max-time 5 "$URL/health" || {
|
||||
echo "no server at $URL: start it with 'go run ./examples/http-linux'" >&2
|
||||
exit 1
|
||||
}
|
||||
local cases=("$@")
|
||||
[ ${#cases[@]} -eq 0 ] && cases=("${ALL[@]}")
|
||||
for c in "${cases[@]}"; do
|
||||
"fuzz_$c" || { echo "unknown case: $c" >&2; exit 1; }
|
||||
done
|
||||
echo "all cases done, server still up"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,475 @@
|
||||
//go:build !tinygo && linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/http/httphi"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
)
|
||||
|
||||
const (
|
||||
kB = 1 << 10
|
||||
listenPort = 8080
|
||||
bufferSizes = 2 * kB
|
||||
// A browser sends around twenty header fields; a request carrying more
|
||||
// than this is answered 431 rather than parsed into memory it was not
|
||||
// given. Each field costs 8 bytes of table.
|
||||
numHeaderFields = 32
|
||||
readTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Credentials the endpoints check. They are in the source on purpose: this is a
|
||||
// target to point a fuzzer at, and a scan is only interesting when something is
|
||||
// there to be found.
|
||||
const (
|
||||
adminUser = "admin"
|
||||
adminPass = "hunter2"
|
||||
sessionToken = "s3cr3t-session-token"
|
||||
)
|
||||
|
||||
// Fixed corpora the handlers answer from, so a path scan separates hits from
|
||||
// misses instead of finding one status code everywhere.
|
||||
var (
|
||||
users = [...]string{"alice", "bob", "carol"}
|
||||
files = [...]string{"readme.txt", "logo.png", "notes.md"}
|
||||
)
|
||||
|
||||
var (
|
||||
flagPort = flag.Int("port", listenPort, "TCP port to listen on")
|
||||
flagVerbose = flag.Bool("v", false, "log every request to stderr; a fuzzer at full rate makes this expensive")
|
||||
flagThreads = flag.Int("threads", 8, "Number of goroutines to spawn.")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if err := run(); err != nil {
|
||||
println("Error:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
println("DONE")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ln, err := net.Listen("tcp", ":"+strconv.Itoa(*flagPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ln.Close()
|
||||
print("listening on http://localhost:", *flagPort, "\n")
|
||||
|
||||
var server Server
|
||||
// One scratch per router goroutine: the router serves that many requests at
|
||||
// once, so a handler always finds one waiting for it.
|
||||
server.initScratch(*flagThreads)
|
||||
// "{$}" matches the empty path and nothing else, so an unregistered path
|
||||
// gets a 404 instead of the homepage. A bare "/" is a catch-all.
|
||||
server.Handle("GET /{$}", server.homepage)
|
||||
server.Handle("GET /health", server.health)
|
||||
server.Handle("GET /search", server.search)
|
||||
server.Handle("POST /login", server.login)
|
||||
server.Handle("GET /admin", server.admin)
|
||||
server.Handle("GET /users/{id}", server.user)
|
||||
server.Handle("GET /files/{path...}", server.file)
|
||||
server.Handle("POST /upload", server.upload)
|
||||
server.Handle("/echo", server.echo) // No method: any method matches.
|
||||
|
||||
var router httphi.Router
|
||||
err = router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: *flagThreads,
|
||||
RequestHeaderBufferSize: bufferSizes,
|
||||
RequestNumHeaderKVCap: numHeaderFields,
|
||||
ResponseHeaderMinBufferSize: bufferSizes,
|
||||
Mux: &server.mux,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer router.Shutdown()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The connection owns the idle policy: a peer that opens a socket and
|
||||
// then stalls fails its read instead of holding a router goroutine.
|
||||
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
err = router.Handle(conn)
|
||||
if err != nil {
|
||||
// Every goroutine is busy and the queue is full. Dropping the
|
||||
// connection is the backpressure: memory stays bounded.
|
||||
slog.Warn("dropped connection", slog.String("remote", conn.RemoteAddr().String()), slog.String("err", err.Error()))
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
htmlHead = `<html><body bgcolor="#000080" text="#00FF00"><center>` +
|
||||
`<marquee><font face="Comic Sans MS" size="5" color="#FFFF00">` +
|
||||
`*** WELCOME TO MY HOMEPAGE ***</font></marquee>` +
|
||||
`<h1><blink>YOU ARE VISITOR #`
|
||||
htmlTail = `!</blink></h1>` +
|
||||
`<font color="#FF00FF">Sign my guestbook!</font><br><hr>` +
|
||||
`<a href="/search?q=go">/search</a> | ` +
|
||||
`<a href="/users/alice">/users/{id}</a> | ` +
|
||||
`<a href="/files/">/files/</a> | ` +
|
||||
`<a href="/admin">/admin</a> | ` +
|
||||
`<a href="/echo">/echo</a> | ` +
|
||||
`<a href="/health">/health</a>` +
|
||||
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
|
||||
// maxPage bounds the rendered page: both halves plus the visitor number.
|
||||
maxPage = len(htmlHead) + 20 + len(htmlTail)
|
||||
)
|
||||
|
||||
// Compile-time check that a scratch's render buffer holds the largest page a
|
||||
// handler builds. A page outgrowing it would grow the buffer on the heap,
|
||||
// which is the one thing this server is written not to do.
|
||||
const _ = uint(outBufferSize - maxPage)
|
||||
|
||||
type Server struct {
|
||||
// Visits counts served requests.
|
||||
Visits atomic.Uint64
|
||||
mux httphi.MuxSlice
|
||||
// scratch is a fixed pool of per-request working memory, see [scratch].
|
||||
scratch chan *scratch
|
||||
}
|
||||
|
||||
// Sizes of a [scratch]. Every one of these is memory spent once per pooled
|
||||
// scratch, so the pool's size times the sum below is what the handlers cost.
|
||||
const (
|
||||
formBufferSize = kB
|
||||
formNumPairs = 16
|
||||
cookieBufferSize = 512
|
||||
cookieNumPairs = 8
|
||||
multipartBufferSize = kB
|
||||
maxMultipartParts = 8
|
||||
outBufferSize = 2 * kB
|
||||
tmpBufferSize = 256
|
||||
)
|
||||
|
||||
// scratch is the memory a handler works in for the length of one request: the
|
||||
// [Exchange] buffer holds the request header and the response header, and
|
||||
// everything a handler parses or renders on top of that lives here.
|
||||
//
|
||||
// Parsers are handed their buffer once and forbidden to grow, so a request that
|
||||
// sends more than the buffer holds is answered an error rather than served from
|
||||
// memory the server never budgeted for.
|
||||
type scratch struct {
|
||||
form httpraw.Form
|
||||
cookie httpraw.Cookie
|
||||
// parts is reused across requests: its [httpraw.MultipartHeader] values keep
|
||||
// the buffers their Name and Filename were copied into.
|
||||
parts []httphi.MultipartSink
|
||||
uploads countingSink
|
||||
|
||||
formBuf [formBufferSize]byte
|
||||
cookieBuf [cookieBufferSize]byte
|
||||
mpBuf [multipartBufferSize]byte
|
||||
// out renders the response body, tmp holds a decoded value being read out of
|
||||
// the request. They are separate because a decode reads into one while the
|
||||
// body is being built in the other.
|
||||
out [outBufferSize]byte
|
||||
tmp [tmpBufferSize]byte
|
||||
}
|
||||
|
||||
// initScratch fills the pool with n scratches and fixes each parser to its
|
||||
// buffer. Sizing n to the router's goroutine count bounds handler memory the
|
||||
// same way the router bounds its own.
|
||||
func (sv *Server) initScratch(n int) {
|
||||
sv.scratch = make(chan *scratch, n)
|
||||
for range n {
|
||||
s := new(scratch)
|
||||
s.form.Reset(s.formBuf[:0], formNumPairs)
|
||||
s.form.EnableBufferGrowth(false)
|
||||
s.cookie.Reset(s.cookieBuf[:0], cookieNumPairs)
|
||||
s.cookie.EnableBufferGrowth(false)
|
||||
s.parts = make([]httphi.MultipartSink, 0, maxMultipartParts)
|
||||
sv.scratch <- s
|
||||
}
|
||||
}
|
||||
|
||||
// acquireScratch takes a scratch out of the pool, blocking while none is free.
|
||||
// With the pool sized to the router's fixed goroutine count it never blocks:
|
||||
// a handler running is a goroutine that has not returned its scratch yet.
|
||||
func (sv *Server) acquireScratch() *scratch { return <-sv.scratch }
|
||||
|
||||
func (sv *Server) releaseScratch(s *scratch) { sv.scratch <- s }
|
||||
|
||||
// Handle registers a handler and wraps it in the middleware every request runs
|
||||
// through: the visit counter and, when asked for, the request log.
|
||||
func (sv *Server) Handle(pattern string, handler httphi.HandlerFunc) {
|
||||
sv.mux.Handle(pattern, func(exch *httphi.Exchange) {
|
||||
sv.Visits.Add(1)
|
||||
if *flagVerbose {
|
||||
println(exch.RequestMethod().String(), exch.MuxPattern())
|
||||
}
|
||||
handler(exch)
|
||||
})
|
||||
}
|
||||
|
||||
func (sv *Server) homepage(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
page := append(s.out[:0], htmlHead...)
|
||||
page = strconv.AppendUint(page, sv.Visits.Load(), 10)
|
||||
page = append(page, htmlTail...)
|
||||
exch.Respond(httphi.StatusOK, "text/html", page)
|
||||
}
|
||||
|
||||
func (sv *Server) health(exch *httphi.Exchange) {
|
||||
exch.RespondString(httphi.StatusOK, "text/plain", "ok\n")
|
||||
}
|
||||
|
||||
// search reads the query string, i.e: "/search?q=go+lang&limit=2". Values are
|
||||
// percent and '+' encoded on the wire, so this is the decoder's surface: a
|
||||
// malformed escape is answered 400 and never half decoded into the response.
|
||||
func (sv *Server) search(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
const decode = true
|
||||
query, present := exch.RequestQueryAppend(s.tmp[:0], "q", decode)
|
||||
if !present {
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "missing or malformed q parameter\n")
|
||||
return
|
||||
}
|
||||
limit := len(users)
|
||||
if raw, present := exch.RequestQueryValue("limit"); present {
|
||||
n, ok := atoiBounded(raw, len(users))
|
||||
if !ok {
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "limit must be a non-negative integer\n")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
body := append(s.out[:0], "query: "...)
|
||||
body = append(body, query...)
|
||||
body = append(body, '\n')
|
||||
for _, user := range users[:limit] {
|
||||
body = append(body, user...)
|
||||
body = append(body, '\n')
|
||||
}
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
}
|
||||
|
||||
// login reads "application/x-www-form-urlencoded" pairs out of the request body
|
||||
// and the query string alike, the body winning a key both carry. It is where a
|
||||
// credential scan lands:
|
||||
//
|
||||
// ffuf -X POST -u http://localhost:8080/login -d 'user=admin&pass=FUZZ' \
|
||||
// -H 'Content-Type: application/x-www-form-urlencoded' -w passwords.txt -fc 401
|
||||
func (sv *Server) login(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
// Checked ahead of the parse so a body in some other encoding is told what
|
||||
// is wrong with it, the parser reporting only that it would not parse.
|
||||
contentType := exch.RequestContentType()
|
||||
if contentType != nil && !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
|
||||
exch.RespondString(httphi.StatusUnsupportedMediaType, "text/plain", "expected application/x-www-form-urlencoded\n")
|
||||
return
|
||||
}
|
||||
const parseQuery, queryWins = true, false
|
||||
err := exch.RequestParseForm(&s.form, parseQuery, queryWins)
|
||||
if err != nil {
|
||||
// A body larger than formBufferSize or more pairs than formNumPairs land
|
||||
// here too: the form was told not to grow, so it refuses instead.
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed or oversized form\n")
|
||||
return
|
||||
}
|
||||
if err = s.form.Decode(); err != nil {
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed percent escape in form\n")
|
||||
return
|
||||
}
|
||||
user, pass := s.form.Get("user"), s.form.Get("pass")
|
||||
if string(user) != adminUser || string(pass) != adminPass {
|
||||
exch.RespondString(httphi.StatusUnauthorized, "text/plain", "bad credentials\n")
|
||||
return
|
||||
}
|
||||
exch.StageHeader("Set-Cookie", "session="+sessionToken+"; Path=/; HttpOnly")
|
||||
exch.RespondString(httphi.StatusOK, "text/plain", "welcome "+adminUser+"\n")
|
||||
}
|
||||
|
||||
// admin is gated on the cookie [Server.login] hands out, so a scan of it fuzzes
|
||||
// the cookie parser:
|
||||
//
|
||||
// ffuf -u http://localhost:8080/admin -b 'session=FUZZ' -w tokens.txt -fc 403
|
||||
func (sv *Server) admin(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
err := exch.RequestParseCookie(&s.cookie, "Cookie")
|
||||
if err != nil {
|
||||
exch.RespondString(httphi.StatusUnauthorized, "text/plain", "no cookie\n")
|
||||
return
|
||||
}
|
||||
if string(s.cookie.Get("session")) != sessionToken {
|
||||
exch.RespondString(httphi.StatusForbidden, "text/plain", "forbidden\n")
|
||||
return
|
||||
}
|
||||
body := append(s.out[:0], "admin panel\n"...)
|
||||
// A valueless attribute, i.e: "session=...; debug", is stored with an empty
|
||||
// key, so a plain Get would never find it.
|
||||
if s.cookie.HasKeyOrSingleValue("debug") {
|
||||
body = append(body, "requests served: "...)
|
||||
body = strconv.AppendUint(body, sv.Visits.Load(), 10)
|
||||
body = append(body, '\n')
|
||||
}
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
}
|
||||
|
||||
// user serves the "{id}" wildcard, a single path segment. Segments are bound
|
||||
// raw, so the value is decoded here and "/users/al%69ce" reaches alice.
|
||||
func (sv *Server) user(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
const decode = true
|
||||
id, err := exch.PathValueAppend(s.tmp[:0], "id", decode)
|
||||
if err != nil {
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed percent escape in path\n")
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if string(id) == user {
|
||||
body := append(s.out[:0], `{"user":"`...)
|
||||
body = append(body, id...)
|
||||
body = append(body, "\"}\n"...)
|
||||
exch.Respond(httphi.StatusOK, "application/json", body)
|
||||
return
|
||||
}
|
||||
}
|
||||
exch.RespondString(httphi.StatusNotFound, "text/plain", "no such user\n")
|
||||
}
|
||||
|
||||
// file serves the "{path...}" wildcard, which takes the rest of the path
|
||||
// including its slashes, so "/files/" and "/files/a/b" both reach here. That
|
||||
// makes it what a recursive scan walks: ffuf -u http://localhost:8080/files/FUZZ -recursion.
|
||||
func (sv *Server) file(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
path := exch.PathValue("path")
|
||||
if len(path) == 0 {
|
||||
body := append(s.out[:0], "index of /files/\n"...)
|
||||
for _, file := range files {
|
||||
body = append(body, file...)
|
||||
body = append(body, '\n')
|
||||
}
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
if string(path) == file {
|
||||
body := append(s.out[:0], "contents of "...)
|
||||
body = append(body, path...)
|
||||
body = append(body, '\n')
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
return
|
||||
}
|
||||
}
|
||||
exch.RespondString(httphi.StatusNotFound, "text/plain", "no such file\n")
|
||||
}
|
||||
|
||||
// upload streams a "multipart/form-data" body, counting each file part instead
|
||||
// of storing it. Parts declare no length, so the body is read a bufferful at a
|
||||
// time and the header of a part that outgrows the buffer is refused 413.
|
||||
//
|
||||
// ffuf -X POST -u http://localhost:8080/upload -w names.txt \
|
||||
// -H 'Content-Type: multipart/form-data; boundary=X' \
|
||||
// -d $'--X\r\nContent-Disposition: form-data; name="f"; filename="FUZZ"\r\n\r\ndata\r\n--X--\r\n'
|
||||
func (sv *Server) upload(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
if !httpraw.MediaTypeIs(exch.RequestContentType(), "multipart/form-data") {
|
||||
exch.RespondString(httphi.StatusUnsupportedMediaType, "text/plain", "expected multipart/form-data\n")
|
||||
return
|
||||
}
|
||||
s.uploads.n = 0
|
||||
// The sink is a field of the scratch, so handing it over as an io.WriteCloser
|
||||
// boxes a pointer that is already on the heap and allocates nothing.
|
||||
parts, err := exch.ReadMultiparts(s.parts[:0], s.mpBuf[:], func(hdr *httpraw.MultipartHeader) io.WriteCloser {
|
||||
if len(hdr.Filename) == 0 {
|
||||
return nil // A plain field, not a file: keep the header, drop the content.
|
||||
}
|
||||
return &s.uploads
|
||||
})
|
||||
// Kept even on failure: the headers parsed so far own buffers worth reusing.
|
||||
// The slice grows with the number of parts, which only the connection's read
|
||||
// deadline bounds, so a real server would cap it.
|
||||
s.parts = parts
|
||||
if err != nil {
|
||||
if err == lneto.ErrShortBuffer {
|
||||
exch.RespondString(httphi.StatusRequestEntityTooLarge, "text/plain", "part header too large\n")
|
||||
} else {
|
||||
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed multipart body\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
body := append(s.out[:0], "parts: "...)
|
||||
body = strconv.AppendInt(body, int64(len(parts)), 10)
|
||||
body = append(body, '\n')
|
||||
for i := range parts {
|
||||
body = append(body, parts[i].Header.Name...)
|
||||
if len(parts[i].Header.Filename) > 0 {
|
||||
body = append(body, " -> "...)
|
||||
body = append(body, parts[i].Header.Filename...)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
}
|
||||
body = append(body, "bytes stored: "...)
|
||||
body = strconv.AppendInt(body, s.uploads.n, 10)
|
||||
body = append(body, '\n')
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
}
|
||||
|
||||
// echo hands back the request line and the header block as the parser stored
|
||||
// it, which is what tells a header fuzzer what its input turned into.
|
||||
func (sv *Server) echo(exch *httphi.Exchange) {
|
||||
s := sv.acquireScratch()
|
||||
defer sv.releaseScratch(s)
|
||||
body := append(s.out[:0], exch.RequestMethodRaw()...)
|
||||
body = append(body, ' ')
|
||||
body = append(body, exch.RequestTarget()...)
|
||||
body = append(body, '\n')
|
||||
if value := exch.RequestHeader("X-Fuzz"); value != nil {
|
||||
body = append(body, "x-fuzz: "...)
|
||||
body = append(body, value...)
|
||||
body = append(body, '\n')
|
||||
}
|
||||
body = append(body, "-- parsed header --\n"...)
|
||||
body = exch.RequestHeaderV1Raw().AppendHeaders(body)
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
}
|
||||
|
||||
// countingSink discards a multipart part and counts what it discarded, standing
|
||||
// in for the file a real upload would write.
|
||||
type countingSink struct{ n int64 }
|
||||
|
||||
func (c *countingSink) Write(b []byte) (int, error) { c.n += int64(len(b)); return len(b), nil }
|
||||
func (c *countingSink) Close() error { return nil }
|
||||
|
||||
// atoiBounded parses a decimal number and clamps it to max, reporting false for
|
||||
// anything that is not one. It works off the bytes rather than converting to a
|
||||
// string, which would allocate on a path every request takes.
|
||||
func atoiBounded(b []byte, max int) (int, bool) {
|
||||
const maxDigits = 9 // Bounded so the accumulator below cannot overflow.
|
||||
if len(b) == 0 || len(b) > maxDigits {
|
||||
return 0, false
|
||||
}
|
||||
n := 0
|
||||
for _, c := range b {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return min(n, max), true
|
||||
}
|
||||
@@ -18,11 +18,12 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
"github.com/soypat/lneto/http/httphi"
|
||||
"github.com/soypat/lneto/internal"
|
||||
"github.com/soypat/lneto/internal/ltesto"
|
||||
"github.com/soypat/lneto/internet/pcap"
|
||||
@@ -34,6 +35,23 @@ import (
|
||||
//go:embed index.html
|
||||
var indexhtml string
|
||||
|
||||
// Router memory. The router allocates all of it on Configure and never again,
|
||||
// so these are the whole cost of serving HTTP over the stack.
|
||||
const (
|
||||
// A browser sends around 700 bytes of header on a landing page request.
|
||||
requestHeaderBuffer = 1024
|
||||
// Response headers reuse whatever the request left unused on top of this,
|
||||
// and the status line does not count towards it.
|
||||
responseHeaderBuffer = 256
|
||||
numHeaderFields = 16
|
||||
// One exchange is allocated per worker, and a worker holds its exchange for
|
||||
// the whole request, so this is what bounds requests served at once.
|
||||
numWorkers = 2
|
||||
// requestTimeout drops a peer that opens a connection and then stalls,
|
||||
// rather than letting it hold one of the workers.
|
||||
requestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var softRand = time.Now().Unix()
|
||||
|
||||
func main() {
|
||||
@@ -225,6 +243,28 @@ func run() (err error) {
|
||||
svPort := uint16(flagPort)
|
||||
fmt.Printf("Listening on %s:%d\n", ipv4.AppendFormatAddr(nil, stack.Addr4()), svPort)
|
||||
|
||||
// Routes are registered before Configure: the router reads the mux to size
|
||||
// the exchanges it allocates, and refuses a mux with nothing registered.
|
||||
server := httpServer{start: time.Now()}
|
||||
// "{$}" matches the empty path and nothing else, so anything unregistered
|
||||
// gets a 404 rather than the index page.
|
||||
server.handle("GET /{$}", server.index)
|
||||
server.handle("GET /stats", server.stats)
|
||||
|
||||
var router httphi.Router
|
||||
err = router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: numWorkers,
|
||||
RequestHeaderBufferSize: requestHeaderBuffer,
|
||||
ResponseHeaderMinBufferSize: responseHeaderBuffer,
|
||||
RequestNumHeaderKVCap: numHeaderFields,
|
||||
Mux: &server.mux,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuring HTTP router: %w", err)
|
||||
}
|
||||
defer router.Shutdown()
|
||||
|
||||
// Serve connections in a loop.
|
||||
for {
|
||||
var conn tcp.Conn
|
||||
@@ -254,65 +294,56 @@ func run() (err error) {
|
||||
continue
|
||||
}
|
||||
fmt.Println("connection established from", net.IP(conn.RemoteAddr()).String())
|
||||
go func() {
|
||||
err = handleConnection(&conn)
|
||||
if err != nil {
|
||||
fmt.Println("handle error:", err)
|
||||
}
|
||||
}()
|
||||
// The connection owns the idle policy: a peer that stalls fails its read
|
||||
// instead of holding a worker. conn is declared inside the loop, so the
|
||||
// worker keeps serving this one while the next iteration listens anew.
|
||||
conn.SetDeadline(time.Now().Add(requestTimeout))
|
||||
err = router.Handle(&conn)
|
||||
if err != nil {
|
||||
// Every worker is busy. Dropping is the backpressure that keeps the
|
||||
// stack's memory bounded, see numWorkers.
|
||||
slog.Warn("dropped connection", slog.String("err", err.Error()))
|
||||
conn.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnection(conn *tcp.Conn) error {
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
// httpServer holds what the handlers answer with. Routes are registered on its
|
||||
// mux before [httphi.Router.Configure] runs, which reads the mux to size the
|
||||
// path values every exchange must hold.
|
||||
type httpServer struct {
|
||||
mux httphi.MuxSlice
|
||||
served atomic.Uint64
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// Read HTTP request.
|
||||
var hdr httpraw.Header
|
||||
var needMore bool = true
|
||||
for needMore {
|
||||
_, err := hdr.ReadFromLimited(conn, 1024)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading request: %w", err)
|
||||
}
|
||||
const asResponse = false
|
||||
needMore, err = hdr.TryParse(asResponse)
|
||||
if err != nil && !needMore {
|
||||
return fmt.Errorf("parsing request: %w", err)
|
||||
}
|
||||
}
|
||||
// handle registers handler and wraps it in the logging and counting every
|
||||
// request goes through, i.e: the "< GET /" line this example has always printed.
|
||||
func (sv *httpServer) handle(pattern string, handler httphi.HandlerFunc) {
|
||||
sv.mux.Handle(pattern, func(exch *httphi.Exchange) {
|
||||
sv.served.Add(1)
|
||||
fmt.Printf("< %s %s\n", exch.RequestMethodRaw(), exch.RequestTarget())
|
||||
handler(exch)
|
||||
})
|
||||
}
|
||||
|
||||
method := string(hdr.Method())
|
||||
uri := string(hdr.RequestURI())
|
||||
fmt.Printf("< %s %s\n", method, uri)
|
||||
// index serves the embedded page. The body goes straight to the connection, so
|
||||
// only its header ever sits in the exchange's buffer and the page's size does
|
||||
// not enter into how the router is configured.
|
||||
func (sv *httpServer) index(exch *httphi.Exchange) {
|
||||
exch.RespondString(httphi.StatusOK, "text/html", indexhtml)
|
||||
}
|
||||
|
||||
// Build response body.
|
||||
|
||||
// Build HTTP response.
|
||||
var resp httpraw.Header
|
||||
resp.SetProtocol("HTTP/1.1")
|
||||
resp.SetStatus("200", "OK")
|
||||
resp.Set("Content-Type", "text/html")
|
||||
resp.Set("Content-Length", strconv.Itoa(len(indexhtml)))
|
||||
resp.Set("Connection", "close")
|
||||
response, err := resp.AppendResponse(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building response: %w", err)
|
||||
}
|
||||
response = append(response, indexhtml...)
|
||||
|
||||
// Send response.
|
||||
_, err = conn.Write(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing response: %w", err)
|
||||
}
|
||||
err = conn.Flush()
|
||||
if err != nil {
|
||||
return fmt.Errorf("flushing response: %w", err)
|
||||
}
|
||||
fmt.Printf("> %d bytes sent\n", len(response))
|
||||
|
||||
conn.Close()
|
||||
return nil
|
||||
// stats reports what the stack has served, which is the quickest way to tell a
|
||||
// working link from a page that came out of a browser cache.
|
||||
func (sv *httpServer) stats(exch *httphi.Exchange) {
|
||||
var buf [128]byte
|
||||
body := append(buf[:0], "requests served: "...)
|
||||
body = strconv.AppendUint(body, sv.served.Load(), 10)
|
||||
body = append(body, "\nuptime: "...)
|
||||
body = append(body, prettyDuration(time.Since(sv.start))...)
|
||||
body = append(body, '\n')
|
||||
exch.Respond(httphi.StatusOK, "text/plain", body)
|
||||
}
|
||||
|
||||
func clear(buf []byte) {
|
||||
@@ -0,0 +1,115 @@
|
||||
//go:build !tinygo && linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto/http/httphi"
|
||||
)
|
||||
|
||||
const (
|
||||
kB = 1 << 10
|
||||
listenPort = 8080
|
||||
bufferSizes = 2 * kB
|
||||
// A browser sends around twenty header fields; a request carrying more
|
||||
// than this is answered 431 rather than parsed into memory it was not
|
||||
// given. Each field costs 8 bytes of table.
|
||||
numHeaderFields = 32
|
||||
numGoroutines = 4
|
||||
readTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
println("Error:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
println("DONE")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ln, err := net.Listen("tcp", ":"+strconv.Itoa(listenPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ln.Close()
|
||||
print("listening on http://localhost:", listenPort, "\n")
|
||||
|
||||
var server Server
|
||||
server.Handle("GET /", server.homepage)
|
||||
|
||||
var router httphi.Router
|
||||
err = router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: numGoroutines,
|
||||
RequestHeaderBufferSize: bufferSizes,
|
||||
RequestNumHeaderKVCap: numHeaderFields,
|
||||
ResponseHeaderMinBufferSize: bufferSizes,
|
||||
Mux: &server.mux,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer router.Shutdown()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The connection owns the idle policy: a peer that opens a socket and
|
||||
// then stalls fails its read instead of holding a router goroutine.
|
||||
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
err = router.Handle(conn)
|
||||
if err != nil {
|
||||
// Every goroutine is busy and the queue is full. Dropping the
|
||||
// connection is the backpressure: memory stays bounded.
|
||||
slog.Warn("dropped connection", slog.String("remote", conn.RemoteAddr().String()), slog.String("err", err.Error()))
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
htmlHead = `<html><body bgcolor="#000080" text="#00FF00"><center>` +
|
||||
`<marquee><font face="Comic Sans MS" size="5" color="#FFFF00">` +
|
||||
`*** WELCOME TO MY HOMEPAGE ***</font></marquee>` +
|
||||
`<h1><blink>YOU ARE VISITOR #`
|
||||
htmlTail = `!</blink></h1>` +
|
||||
`<font color="#FF00FF">Sign my guestbook!</font>` +
|
||||
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
|
||||
// maxPage bounds the rendered page: both halves plus the visitor number.
|
||||
maxPage = len(htmlHead) + 20 + len(htmlTail)
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
// visits counts served requests.
|
||||
Visits atomic.Uint64
|
||||
mux httphi.MuxSlice
|
||||
}
|
||||
|
||||
func (sv *Server) Handle(pattern string, handler httphi.HandlerFunc) {
|
||||
// Middleware for all incoming requests declared here.
|
||||
sv.mux.Handle(pattern, func(exch *httphi.Exchange) {
|
||||
println(exch.RequestMethod().String(), exch.MuxPattern())
|
||||
handler(exch)
|
||||
})
|
||||
}
|
||||
|
||||
func (sv *Server) homepage(exch *httphi.Exchange) {
|
||||
var page [maxPage]byte
|
||||
n := copy(page[:], htmlHead)
|
||||
n += len(strconv.AppendUint(page[n:n], sv.Visits.Add(1), 10))
|
||||
n += copy(page[n:], htmlTail)
|
||||
exch.Respond(200, "text/html", page[:n])
|
||||
}
|
||||
|
||||
func (sv *Server) form(exch *httphi.Exchange) {
|
||||
|
||||
}
|
||||
@@ -25,9 +25,9 @@ func run() error {
|
||||
flag.IntVar(&port, "lport", 13337, "Local port over which to hit server")
|
||||
flag.Parse()
|
||||
// Prepare GET request.
|
||||
var hdr httpraw.Header
|
||||
var hdr httpraw.HeaderV1
|
||||
hdr.SetMethod("GET")
|
||||
hdr.SetRequestURI("/")
|
||||
hdr.SetRequestTarget("/")
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
req, err := hdr.AppendRequest(nil)
|
||||
if err != nil {
|
||||
@@ -50,7 +50,7 @@ func run() error {
|
||||
}()
|
||||
fmt.Println(time.Now().Format("15:04:05.000"), "writing...")
|
||||
conn.Write(req)
|
||||
hdr.Reset(nil)
|
||||
hdr.Reset(nil, 0) // No preallocation. Both key/value and raw buffer will grow and allocate.
|
||||
var needMore bool = true
|
||||
for needMore {
|
||||
_, err = hdr.ReadFromLimited(conn, 1024)
|
||||
@@ -168,7 +168,7 @@ func (d *dhcpInterceptor) buildDHCPResponse(buf []byte) (int, error) {
|
||||
// DHCP responses must be broadcast since the client doesn't have
|
||||
// an IP configured yet and the stack would drop unicast packets.
|
||||
*efrm.DestinationHardwareAddr() = ethernet.BroadcastAddr()
|
||||
*ifrm.DestinationAddr() = [4]byte{255, 255, 255, 255}
|
||||
*ifrm.DestinationAddr() = ipv4.BroadcastAddr()
|
||||
ifrm.SetTotalLength(totalIPLen)
|
||||
ufrm.SetLength(udpLen)
|
||||
// Source and destination IPs already set by dhcpv4.Server.Encapsulate.
|
||||
|
||||
@@ -305,9 +305,9 @@ func run() (err error) {
|
||||
})
|
||||
|
||||
timeHTTPCreate := timer("create HTTP GET request")
|
||||
var hdr httpraw.Header
|
||||
var hdr httpraw.HeaderV1
|
||||
hdr.SetMethod("GET")
|
||||
hdr.SetRequestURI("/")
|
||||
hdr.SetRequestTarget("/")
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
hdr.Set("Host", flagHostToResolve)
|
||||
hdr.Set("User-Agent", "lneto")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# httφ
|
||||
|
||||
Heapless HTTP/1.1 router with `net/http`-shaped handlers. Benchmarked against `net/http` and friends at [**soypat/httpbench**](https://github.com/soypat/httpbench).
|
||||
|
||||
## Why
|
||||
|
||||
`net/http` allocates per request: `Request`, header map, response buffer, goroutine. Fine on a
|
||||
server, fatal on a microcontroller. httφ pays that cost once, at `Router.Configure`:
|
||||
|
||||
- Exchanges and goroutines are fixed there. Serving allocates nothing; memory does not grow with load.
|
||||
- No free exchange means `Handle` refuses the connection unclosed, rather than allocating one more of everything.
|
||||
- `Handle` takes an `io.ReadWriteCloser`, so the router runs over a raw socket, an [`lneto`](https://github.com/soypat/lneto) TCP stack or a test pipe. No listener, no OS, no clock.
|
||||
|
||||
Parsing is [`httpraw`](../httpraw).
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
var mux httphi.MuxSlice
|
||||
mux.Handle("GET /", func(ex *httphi.Exchange) {
|
||||
ex.WriteBody([]byte("hello world"))
|
||||
})
|
||||
|
||||
var router httphi.Router
|
||||
err := router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: 4, // 4 workers, 4 exchanges, allocated here and never again.
|
||||
RequestHeaderBufferSize: 1024,
|
||||
ResponseHeaderMinBufferSize: 32, // Shares the request buffer.
|
||||
RequestNumHeaderKVCap: 32,
|
||||
Mux: &mux,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for {
|
||||
conn, err := listener.Accept() // Accepting is the caller's job.
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err = router.Handle(conn); err != nil {
|
||||
conn.Close() // Refused: never blocks, never queues unboundedly.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`FixedNumGoroutines: -1` gives the unbounded flavor, a goroutine and an exchange per connection.
|
||||
|
||||
Runnable server over raw Linux sockets, plus query, form and multipart handlers:
|
||||
[`example_test.go`](./example_test.go).
|
||||
|
||||
|
||||
## Naming
|
||||
|
||||
Gonna be honest with y'all. I initially wanted it to be named `httplo` until I saw I could write `httphi.MethHead` with a small change.
|
||||
@@ -0,0 +1,137 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// benchConn replays a fixed request and discards the response. It allocates
|
||||
// nothing itself so benchmark alloc counts belong to the package under test.
|
||||
type benchConn struct {
|
||||
request string
|
||||
read int
|
||||
written int
|
||||
}
|
||||
|
||||
func (c *benchConn) rewind() { c.read, c.written = 0, 0 }
|
||||
|
||||
func (c *benchConn) Read(b []byte) (int, error) {
|
||||
if c.read >= len(c.request) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(b, c.request[c.read:])
|
||||
c.read += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *benchConn) Write(b []byte) (int, error) {
|
||||
c.written += len(b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *benchConn) Close() error { return nil }
|
||||
|
||||
// benchBody is package level: converting a string literal to []byte inside the
|
||||
// handler would allocate on every request and hide the router's own cost.
|
||||
var benchBody = []byte("hello world")
|
||||
|
||||
func benchExchange(b *testing.B, conn conn) *Exchange {
|
||||
b.Helper()
|
||||
const bufferSize = 1024
|
||||
const numHeaderCap = 2
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2*bufferSize),
|
||||
RequestBufferLim: bufferSize,
|
||||
NumHeaderKVCap: numHeaderCap,
|
||||
})
|
||||
if !exch.Acquire(conn) {
|
||||
b.Fatal("fresh exchange failed to acquire connection")
|
||||
}
|
||||
return exch
|
||||
}
|
||||
|
||||
// BenchmarkHandle measures a whole exchange: read, parse, mux and respond.
|
||||
func BenchmarkHandle(b *testing.B) {
|
||||
expect := []byte("123")
|
||||
buf := make([]byte, 64)
|
||||
for _, bb := range []struct {
|
||||
name string
|
||||
request string
|
||||
handler HandlerFunc
|
||||
}{
|
||||
{
|
||||
name: "GETWithHeadersAndQuery",
|
||||
request: "GET /?abc=123 HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n",
|
||||
handler: func(ex *Exchange) {
|
||||
ex.StageHeader("Content-Type", "text/plain")
|
||||
ex.StageHeaderIntBase("Content-Length", int64(len(benchBody)), 10)
|
||||
data, present := ex.RequestQueryAppend(buf[:0], "abc", true)
|
||||
if !present || !internal.BytesEqual(data, expect) {
|
||||
panic("invalid result")
|
||||
}
|
||||
ex.WriteBody(benchBody)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NotFound",
|
||||
request: "GET /nowhere HTTP/1.1\r\nHost: tinygo.org\r\n\r\n",
|
||||
handler: nil, // Unregistered: exercises the 404 path.
|
||||
},
|
||||
} {
|
||||
b.Run(bb.name, func(b *testing.B) {
|
||||
var mux MuxSlice
|
||||
if bb.handler != nil {
|
||||
mux.Handle("GET /", bb.handler)
|
||||
}
|
||||
conn := &benchConn{request: bb.request}
|
||||
exch := benchExchange(b, conn)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bb.request)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
conn.rewind()
|
||||
exch.Release()
|
||||
exch.Acquire(conn)
|
||||
Handle(exch, &mux, nopBackoff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// benchForm is package level so the Form's pair slice is reused across requests,
|
||||
// as a real handler holding one per goroutine would.
|
||||
var benchForm httpraw.Form
|
||||
|
||||
// BenchmarkRequestParseForm measures reading and parsing a urlencoded body into
|
||||
// a buffer the caller owns. Nothing on the path may allocate.
|
||||
func BenchmarkRequestParseForm(b *testing.B) {
|
||||
const request = "POST /f HTTP/1.1\r\nHost: tinygo.org\r\n" +
|
||||
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 27\r\n\r\n" +
|
||||
"user=gopher&msg=hello+world"
|
||||
// The form owns its memory now, so pre-size it and forbid growth: an
|
||||
// allocation on this path is the failure the benchmark is watching for.
|
||||
benchForm.Reset(make([]byte, 0, 64), 2)
|
||||
benchForm.EnableBufferGrowth(false)
|
||||
var mux MuxSlice
|
||||
mux.Handle("POST /f", func(ex *Exchange) {
|
||||
err := ex.RequestParseForm(&benchForm, false, false)
|
||||
if err != nil || benchForm.Len() != 2 {
|
||||
panic("invalid result")
|
||||
}
|
||||
})
|
||||
conn := &benchConn{request: request}
|
||||
exch := benchExchange(b, conn)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(request)))
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
conn.rewind()
|
||||
exch.Release()
|
||||
exch.Acquire(conn)
|
||||
Handle(exch, &mux, nopBackoff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package httphi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/soypat/lneto/http/httphi"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
)
|
||||
|
||||
// ExampleRouter_linux goes over how to setup a linux server using raw linux connections.
|
||||
// See [ExampleMuxSlice_query_forms_multipart] on how to define handlers for common HTTP processing.
|
||||
func ExampleRouter() {
|
||||
// Chrome tends to send ~700 bytes on a typical landing page request.
|
||||
const requestBuffer = 1024
|
||||
const numHeaderKV = requestBuffer / 32 //
|
||||
var mux httphi.MuxSlice
|
||||
mux.Handle("GET /", func(ex *httphi.Exchange) {
|
||||
ex.WriteBody([]byte("hello world"))
|
||||
})
|
||||
var router httphi.Router
|
||||
err := router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: -1, // Unbounded goroutines and allocations.
|
||||
RequestHeaderBufferSize: requestBuffer,
|
||||
ResponseHeaderMinBufferSize: 32, // Shared buffer with Request, not strictly necessary, especially if not sending headers.
|
||||
RequestNumHeaderKVCap: numHeaderKV,
|
||||
NormalizeOutgoingKeys: true,
|
||||
Mux: &mux,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
const port = ":8080"
|
||||
listener, err := net.Listen("tcp", port)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("server up at http://localhost%s", port)
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = router.Handle(conn)
|
||||
if err != nil {
|
||||
log.Println("httphi.Router failed to handle connection:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleMuxSlice_query_forms_multipart goes over how to define Handlers (HandleFunc).
|
||||
// See [ExampleRouter_linux] for how to setup the server.
|
||||
func ExampleMuxSlice_query_forms_multipart() {
|
||||
|
||||
var mux httphi.MuxSlice
|
||||
|
||||
mux.Handle("/users/{id}", func(ex *httphi.Exchange) {
|
||||
userID := ex.PathValue("id")
|
||||
fmt.Printf("someone requested data for user %s\n", userID)
|
||||
})
|
||||
|
||||
mux.Handle("/query", func(ex *httphi.Exchange) {
|
||||
// query parameter in URL.
|
||||
const decodeQuery = true
|
||||
const queryKey = "search"
|
||||
valueRaw, present := ex.RequestQueryValue(queryKey)
|
||||
if !present {
|
||||
return
|
||||
}
|
||||
valueDecoded, present := ex.RequestQueryAppend(nil, queryKey, decodeQuery)
|
||||
fmt.Printf("got query=%v %s=%s (raw:%s)\n", present, queryKey, valueDecoded, valueRaw)
|
||||
})
|
||||
|
||||
mux.Handle("GET /form", func(ex *httphi.Exchange) {
|
||||
// Request Body Form. The form owns the memory: hand it a buffer and
|
||||
// forbid growth to bound what a request may spend.
|
||||
var form httpraw.Form
|
||||
form.Reset(make([]byte, 0, 1024), 8) // Room for 8 pairs.
|
||||
form.EnableBufferGrowth(false)
|
||||
err := ex.RequestParseForm(&form, false, false)
|
||||
if err != nil {
|
||||
ex.WriteHeader(httphi.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i := range form.Len() {
|
||||
k, v := form.Pair(i)
|
||||
fmt.Printf("received form value %d: %s=%s\n", i, k, v)
|
||||
}
|
||||
})
|
||||
|
||||
mux.Handle("GET /file-upload", func(ex *httphi.Exchange) {
|
||||
// File upload directly onto server using multipart.
|
||||
formbuf := make([]byte, 1024)
|
||||
_, err := ex.ReadMultiparts(nil, formbuf, func(hdr *httpraw.MultipartHeader) io.WriteCloser {
|
||||
fp, err := os.Create(string(hdr.Filename))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return fp // Will write full file contents to fp.
|
||||
})
|
||||
if err != nil {
|
||||
ex.WriteHeader(httphi.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// maxStatusLine bounds the response status line: "HTTP/1.1 " + 3 digit code +
|
||||
// " " + longest [StatusText] + CRLF.
|
||||
const maxStatusLine = len("HTTP/1.1 ") + 3 + 1 + len("Network Authentication Required") + 2
|
||||
|
||||
// Exchange is a single request-response cycle over a connection, playing the
|
||||
// part of both http.Request and http.ResponseWriter: Request* methods read the
|
||||
// request, [Exchange.StageHeader] and [Exchange.WriteBody] produce the response.
|
||||
// A [Router] owns a fixed pool of them, which is what bounds its memory.
|
||||
//
|
||||
// Request and response share one buffer, the response header being written over
|
||||
// the bytes that follow the parsed request header. Read the request body with
|
||||
// [Exchange.ReadBody] before setting response headers.
|
||||
type Exchange struct {
|
||||
acquired atomic.Bool
|
||||
gen atomic.Uint32
|
||||
respTopBuf [maxStatusLine]byte
|
||||
respTopWritten uint8
|
||||
|
||||
rawbuf []byte
|
||||
respHeaderOff uint16
|
||||
respHeaderLen uint16
|
||||
reqHdr httpraw.HeaderV1
|
||||
pathValues []PathValue
|
||||
// bodyRW is the reader handed to [httpraw.Form.ReadLimited], kept here so
|
||||
// boxing it into an io.Reader allocates nothing per request.
|
||||
bodyRW ExchangeRW
|
||||
|
||||
hijacked bool
|
||||
rw conn
|
||||
|
||||
matchedPattern string
|
||||
|
||||
respRemains int
|
||||
respErr error // Sticky: response is unrecoverable once a write fails.
|
||||
headerWritten bool
|
||||
normalizeKeys bool
|
||||
nextFree *Exchange
|
||||
readErr error
|
||||
}
|
||||
|
||||
// ExchangeConfig is the memory an [Exchange] is fixed to for the rest of its
|
||||
// life by [Exchange.Configure]. A [Router] derives one per exchange from its
|
||||
// [RouterConfig], which is what bounds the router's memory.
|
||||
//
|
||||
// Fields open with Required, Conditional or Optional and the constraint in
|
||||
// brackets, as in [RouterConfig].
|
||||
type ExchangeConfig struct {
|
||||
// Required [non-empty] single buffer holding the request header, the response
|
||||
// header and any surplus body. See [Exchange.UnsafeRawBuffer].
|
||||
RawBuf []byte
|
||||
// Required [<=len(RawBuf)] bytes of RawBuf reserved for the request header,
|
||||
// the rest being the response. Configure panics if it exceeds RawBuf.
|
||||
RequestBufferLim int
|
||||
// Required [>0] request header fields that may be parsed. A request carrying
|
||||
// more is answered 431, see [httpraw.ErrHeaderTooMany].
|
||||
NumHeaderKVCap int
|
||||
// Optional [any] normalization of staged response header keys as they are
|
||||
// written, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
// Optional [any] cap holding the request header to RequestBufferLim rather than
|
||||
// growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
|
||||
NoRequestBufferGrowth bool
|
||||
// Conditional [>=the most wildcards any one registered pattern binds] number of
|
||||
// path values bindable, read back with [Exchange.PathValue]. A pattern binding
|
||||
// more never matches, see [SetPathValues]. Zero suits a mux of literal patterns.
|
||||
MaxPathValues int
|
||||
}
|
||||
|
||||
// HijackRaw is a low-level implementation of http.Hijacker interface.
|
||||
// A Hijack method is not exposed due to heap allocation implications and correctness concerns.
|
||||
// Below is what an actual implementation may look like:
|
||||
//
|
||||
// func (exch *Exchange) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
// conn, ok := exch.rw.(net.Conn)
|
||||
// if !ok {
|
||||
// return nil, nil, errors.New("net.Conn not implemented")
|
||||
// }
|
||||
// _, data, err := exch.HijackRaw(nil)
|
||||
// if err != nil {
|
||||
// return nil, nil, err
|
||||
// }
|
||||
// var rd *bufio.ReadWriter
|
||||
// if len(data) > 0 {
|
||||
// rd = &bufio.ReadWriter{Reader: bufio.NewReader(bytes.NewReader(data))}
|
||||
// }
|
||||
// return conn, rd, nil
|
||||
// }
|
||||
func (exch *Exchange) HijackRaw(dstBody []byte) (conn, []byte, error) {
|
||||
data, err := exch.remainingSurplusBody()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
exch.hijacked = true
|
||||
dstBody = append(dstBody, data...)
|
||||
return exch.rw, dstBody, nil
|
||||
}
|
||||
|
||||
// Configure sets the memory the exchange works with for the rest of its life:
|
||||
// rawbuf holds the request header, the response header and any surplus body,
|
||||
// of which the first requestLim bytes are reserved for the request header.
|
||||
// Panics if requestLim exceeds the buffer. Set normalizeKeys to normalize
|
||||
// outgoing header keys, i.e: "content-type" to "Content-Type".
|
||||
func (exch *Exchange) Configure(cfg ExchangeConfig) {
|
||||
respSize := len(cfg.RawBuf) - cfg.RequestBufferLim
|
||||
if respSize < 0 {
|
||||
panic("request lim larger than buffer")
|
||||
}
|
||||
exch.rawbuf = cfg.RawBuf
|
||||
exch.reqHdr.Reset(cfg.RawBuf[:0:cfg.RequestBufferLim], cfg.NumHeaderKVCap)
|
||||
exch.reqHdr.ConfigBufferGrowth(!cfg.NoRequestBufferGrowth)
|
||||
exch.normalizeKeys = cfg.NormalizeOutgoingKeys
|
||||
internal.SliceReuse(&exch.pathValues, cfg.MaxPathValues)
|
||||
exch.pathValues = exch.pathValues[:cfg.MaxPathValues]
|
||||
}
|
||||
|
||||
// Acquire claims the exchange for conn and resets it to serve a new request,
|
||||
// reusing the buffer set by [Exchange.Configure]. Returns false if the exchange
|
||||
// is already serving, in which case conn is untouched.
|
||||
func (exch *Exchange) Acquire(conn conn) bool {
|
||||
if !exch.acquired.CompareAndSwap(false, true) {
|
||||
return false
|
||||
}
|
||||
exch.matchedPattern = ""
|
||||
exch.gen.Add(1)
|
||||
exch.readErr = nil
|
||||
exch.respErr = nil
|
||||
exch.hijacked = false
|
||||
exch.respTopWritten = 0
|
||||
exch.respHeaderOff = 0
|
||||
exch.respHeaderLen = 0
|
||||
exch.respRemains = 0
|
||||
exch.rw = conn
|
||||
exch.headerWritten = false
|
||||
exch.nextFree = nil
|
||||
clear(exch.pathValues)
|
||||
exch.reqHdr.Reset(nil, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
// Release closes the exchange's connection and frees the exchange for a future
|
||||
// [Exchange.Acquire]. The connection is left open if the handler took ownership
|
||||
// of it with [Exchange.HijackRaw].
|
||||
func (exch *Exchange) Release() {
|
||||
if !exch.hijacked {
|
||||
exch.rw.Close()
|
||||
}
|
||||
exch.rw = nil
|
||||
exch.gen.Add(1)
|
||||
exch.acquired.Store(false)
|
||||
}
|
||||
|
||||
// UnsafeRawBuffer returns the contiguous buffer owned by [Exchange] being used for the request and response.
|
||||
//
|
||||
// Writing to it will mangle the entire request header+body and/or any staged response headers.
|
||||
// Does not return the buffer used for the response first line so can be safely
|
||||
// written to and used without modifying the staged response first line.
|
||||
//
|
||||
// Staging headers will write to this buffer so use mindfully.
|
||||
// To access only the request header buffer portion use [httpraw.HeaderV1.BufferRaw] limited
|
||||
// to [httpraw.HeaderV1.BufferParsed] as returned by [Exchange.requestHeaderRaw].
|
||||
// Writing to this section will not change the contents read by [Exchange.ReadBody].
|
||||
//
|
||||
// In [Router] context, the size of this buffer is influenced directly by [RouterConfig] HeaderBufferSize fields.
|
||||
func (exch *Exchange) UnsafeRawBuffer() []byte { return exch.rawbuf }
|
||||
|
||||
// RequestHeaderV1Raw returns the parsed request header for access beyond the
|
||||
// Request* methods, such as [httpraw.HeaderV1.ForEach]. Valid until the exchange
|
||||
// is released, and writing to it corrupts the response.
|
||||
func (exch *Exchange) RequestHeaderV1Raw() *httpraw.HeaderV1 { return &exch.reqHdr }
|
||||
|
||||
// StageHeader stages a response header field, written on the first
|
||||
// [Exchange.FlushHeader], [Exchange.WriteHeader] or [Exchange.WriteBody].
|
||||
// Returns false and drops the field if the response buffer cannot fit it.
|
||||
// Has no effect once the header has been written.
|
||||
func (exch *Exchange) StageHeader(key, value string) (enoughMemory bool) {
|
||||
if exch.headerWritten {
|
||||
return false
|
||||
}
|
||||
off := int(exch.respHeaderOff) + int(exch.respHeaderLen)
|
||||
free := len(exch.rawbuf) - off
|
||||
// Field costs key+':'+value+CRLF, plus the CRLF [Exchange.FlushHeader]
|
||||
// appends past the last field to close the header block.
|
||||
if len(key)+len(value)+len(":\r\n")+len("\r\n") > free {
|
||||
exch.respErr = lneto.ErrBufferFull // Omit writing header back to prevent incomplete response.
|
||||
return false
|
||||
}
|
||||
n := copy(exch.rawbuf[off:], key)
|
||||
if exch.normalizeKeys {
|
||||
httpraw.NormalizeHeaderKey(exch.rawbuf[off : off+n])
|
||||
}
|
||||
exch.rawbuf[off+n] = ':'
|
||||
n++
|
||||
n += copy(exch.rawbuf[off+n:], value)
|
||||
exch.rawbuf[off+n] = '\r'
|
||||
exch.rawbuf[off+n+1] = '\n'
|
||||
n += 2
|
||||
exch.respHeaderLen += uint16(n)
|
||||
return true
|
||||
}
|
||||
|
||||
// StageHeaderBytes is [Exchange.StageHeader] with a byte slice value, i.e: a
|
||||
// field copied out of the request. The value is not retained.
|
||||
func (exch *Exchange) StageHeaderBytes(key string, value []byte) (enoughMemory bool) {
|
||||
return exch.StageHeader(key, b2s(value))
|
||||
}
|
||||
|
||||
// StageHeaderInt is [Exchange.StageHeaderIntBase] in base 10, which is the base
|
||||
// every HTTP field value carrying a number uses, i.e: Content-Length.
|
||||
func (exch *Exchange) StageHeaderInt(key string, value int64) (enoughMemory bool) {
|
||||
return exch.StageHeaderIntBase(key, value, 10)
|
||||
}
|
||||
|
||||
// StageHeaderIntBase is [Exchange.StageHeader] with an integer value, i.e: Content-Length.
|
||||
// It formats the value directly into the response buffer without allocating.
|
||||
// base must be in the range 10..36; lower bases are dropped, no HTTP header
|
||||
// field value is written below base 10.
|
||||
func (exch *Exchange) StageHeaderIntBase(key string, value int64, base int) (enoughMemory bool) {
|
||||
if exch.headerWritten || base < 10 || base > 36 {
|
||||
return false
|
||||
}
|
||||
off := int(exch.respHeaderOff) + int(exch.respHeaderLen)
|
||||
free := len(exch.rawbuf) - off
|
||||
if len(key)+internal.IntLen(value, base)+len(":\r\n")+len("\r\n") > free {
|
||||
exch.respErr = lneto.ErrBufferFull // Omit writing header back to prevent incomplete response.
|
||||
return false
|
||||
}
|
||||
n := copy(exch.rawbuf[off:], key)
|
||||
if exch.normalizeKeys {
|
||||
httpraw.NormalizeHeaderKey(exch.rawbuf[off : off+n])
|
||||
}
|
||||
exch.rawbuf[off+n] = ':'
|
||||
n++
|
||||
n += len(strconv.AppendInt(exch.rawbuf[off+n:off+n], value, base))
|
||||
exch.rawbuf[off+n] = '\r'
|
||||
exch.rawbuf[off+n+1] = '\n'
|
||||
n += 2
|
||||
exch.respHeaderLen += uint16(n)
|
||||
return true
|
||||
}
|
||||
|
||||
// StageStatus prepares the status line for the given code without writing
|
||||
// it, i.e: "HTTP/1.1 404 Not Found". Codes with no [StatusText] get an empty
|
||||
// reason phrase. Has no effect once the header has been written.
|
||||
func (exch *Exchange) StageStatus(code int) {
|
||||
if code >= 1000 || exch.headerWritten {
|
||||
return
|
||||
} else if code == 200 {
|
||||
// Common case.
|
||||
exch.respTopWritten = uint8(copy(exch.respTopBuf[:], "HTTP/1.1 200 OK\r\n"))
|
||||
return
|
||||
}
|
||||
n := copy(exch.respTopBuf[:], "HTTP/1.1 ")
|
||||
n += len(strconv.AppendInt(exch.respTopBuf[n:n], int64(code), 10))
|
||||
text := StatusText(code)
|
||||
exch.respTopBuf[n] = ' '
|
||||
n++
|
||||
n += copy(exch.respTopBuf[n:], text)
|
||||
exch.respTopBuf[n] = '\r'
|
||||
exch.respTopBuf[n+1] = '\n'
|
||||
exch.respTopWritten = uint8(n + 2)
|
||||
}
|
||||
|
||||
// WriteHeader sends the status line for code along with the staged header
|
||||
// fields. Only the first call reaches the wire, as in http.ResponseWriter.
|
||||
func (exch *Exchange) WriteHeader(code int) (n int, err error) {
|
||||
if !exch.headerWritten {
|
||||
exch.StageStatus(code)
|
||||
n, err = exch.FlushHeader()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Respond writes a complete response in one call: Content-Type, a Content-Length
|
||||
// taken from len(body), the status line and the body. An empty contentType
|
||||
// stages no Content-Type field, for a code that carries no entity.
|
||||
//
|
||||
// It also stages "Connection: close", the router serving one exchange per
|
||||
// connection, so a peer never waits on a response that is not coming.
|
||||
//
|
||||
// Returns [Exchange.ResponseError]: staged fields that did not fit and failed
|
||||
// writes are both reported there, so a truncated response cannot pass silently.
|
||||
func (exch *Exchange) Respond(code int, contentType string, body []byte) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBody(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// RespondString is [Exchange.Respond] with a string body, saving the conversion.
|
||||
func (exch *Exchange) RespondString(code int, contentType, body string) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBodyString(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// stageResponse stages the fields and status line a complete response needs.
|
||||
// Drops are recorded on respErr by the Stage* calls, so [Exchange.WriteBody]
|
||||
// declines to write a partial header afterwards.
|
||||
func (exch *Exchange) stageResponse(code int, contentType string, bodyLen int) {
|
||||
if contentType != "" {
|
||||
exch.StageHeader("Content-Type", contentType)
|
||||
}
|
||||
exch.StageHeaderInt("Content-Length", int64(bodyLen))
|
||||
// One exchange per connection today, so the peer is told not to wait for a
|
||||
// second response on it. Revisit once the router loops exchanges.
|
||||
exch.StageHeader("Connection", "close")
|
||||
exch.StageStatus(code)
|
||||
}
|
||||
|
||||
// ResponseError returns any error encountered during staging of headers or during writing of response.
|
||||
// Provides an ergonomic way of checking if one ran out of buffer space after staging all headers with [Exchange.StageHeader].
|
||||
func (exch *Exchange) ResponseError() error {
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// FlushHeader writes the status line and staged header fields to the connection
|
||||
// and returns the bytes written, defaulting to a 200 status if none was staged.
|
||||
// Does nothing if the header was already written.
|
||||
func (exch *Exchange) FlushHeader() (int, error) {
|
||||
if exch.respErr != nil {
|
||||
return 0, exch.respErr
|
||||
} else if exch.headerWritten {
|
||||
return 0, nil
|
||||
}
|
||||
if exch.respTopWritten == 0 {
|
||||
exch.StageStatus(200)
|
||||
}
|
||||
exch.headerWritten = true
|
||||
ng, err := exch.rw.Write(exch.respTopBuf[:exch.respTopWritten])
|
||||
if err != nil {
|
||||
exch.respErr = err
|
||||
return ng, err
|
||||
}
|
||||
off := int(exch.respHeaderOff)
|
||||
headers := exch.rawbuf[off : off+int(exch.respHeaderLen)+2]
|
||||
headers[len(headers)-1] = '\n'
|
||||
headers[len(headers)-2] = '\r'
|
||||
ng2, err := exch.rw.Write(headers)
|
||||
exch.respErr = err
|
||||
return ng + ng2, err
|
||||
}
|
||||
|
||||
// ExchangeRW is an [io.ReadWriteCloser] view of an [Exchange] wrapping
|
||||
// [Exchange.ReadBody] and [Exchange.WriteBody] methods.
|
||||
//
|
||||
// Exchanges are pooled and reused, so a handle records the exchange generation
|
||||
// it was taken at and refuses to touch the connection once that exchange moves
|
||||
// on to another request. Obtain one with [Exchange.ReadWriter].
|
||||
type ExchangeRW struct {
|
||||
gen uint32
|
||||
exch *Exchange
|
||||
}
|
||||
|
||||
// IsValid returns true while the handle still refers to the request it was
|
||||
// taken from, i.e: false once the exchange was released.
|
||||
func (rw *ExchangeRW) IsValid() bool {
|
||||
return rw.gen == rw.exch.gen.Load() && rw.exch.acquired.Load()
|
||||
}
|
||||
|
||||
func (rw *ExchangeRW) validate() error {
|
||||
if !rw.IsValid() {
|
||||
return net.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write writes response body bytes. See [Exchange.WriteBody].
|
||||
// Fails with [net.ErrClosed] once the handle is no longer valid.
|
||||
func (rw *ExchangeRW) Write(buf []byte) (int, error) {
|
||||
if err := rw.validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.exch.WriteBody(buf)
|
||||
}
|
||||
|
||||
// WriteString wraps [Exchange.WriteBodyString]. Fails if handle no longer valid.
|
||||
func (rw *ExchangeRW) WriteString(s string) (int, error) {
|
||||
if err := rw.validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.exch.WriteBodyString(s)
|
||||
}
|
||||
|
||||
// Read reads request body bytes. See [Exchange.ReadBody].
|
||||
// Fails with [net.ErrClosed] once the handle is no longer valid.
|
||||
func (rw *ExchangeRW) Read(buf []byte) (int, error) {
|
||||
if err := rw.validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.exch.ReadBody(buf)
|
||||
}
|
||||
|
||||
// Close invalidates this handle so later reads and writes fail. It does not
|
||||
// close the connection nor end the exchange, both of which the [Router] owns.
|
||||
func (rw *ExchangeRW) Close() error {
|
||||
if err := rw.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
rw.gen--
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadWriter fills dst with a stream view of the exchange, valid until the
|
||||
// exchange is released. The caller owns dst, so a handler may keep one and
|
||||
// refill it every request without allocating.
|
||||
func (exch *Exchange) ReadWriter(dst *ExchangeRW) {
|
||||
dst.gen = exch.gen.Load()
|
||||
dst.exch = exch
|
||||
}
|
||||
|
||||
// WriteBodyString implements [io.StringWriter] by unsafe conversion.
|
||||
// Most underlying [io.Writer] implementations are TCP transport and not modify/own the underlying buffer.
|
||||
func (exch *Exchange) WriteBodyString(buf string) (int, error) {
|
||||
return exch.WriteBody(unsafe.Slice(unsafe.StringData(buf), len(buf)))
|
||||
}
|
||||
|
||||
// WriteBody writes response body bytes, flushing the header first if the handler
|
||||
// has not written it yet. Once a write to the connection fails the response is
|
||||
// unrecoverable and every later write returns that same error, so a body never
|
||||
// reaches the wire without its header.
|
||||
func (exch *Exchange) WriteBody(buf []byte) (int, error) {
|
||||
if exch.respErr != nil {
|
||||
return 0, exch.respErr
|
||||
} else if !exch.headerWritten {
|
||||
_, err := exch.FlushHeader()
|
||||
if err != nil {
|
||||
return 0, err // Body must not reach the wire without its header.
|
||||
}
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := exch.rw.Write(buf)
|
||||
exch.respErr = err
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadBody reads the request body into dst, starting with the bytes that
|
||||
// arrived in the same read as the header and continuing from the connection.
|
||||
// The exchange does not know the body's length: use Content-Length or the
|
||||
// transfer encoding to know when to stop reading.
|
||||
func (exch *Exchange) ReadBody(dst []byte) (n int, _ error) {
|
||||
if exch.respRemains > 0 {
|
||||
toRead, err := exch.remainingSurplusBody()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n = copy(dst, toRead)
|
||||
exch.respRemains -= n
|
||||
// hand over what already arrived since conn might have
|
||||
// exhausted data and could block indefinetely.
|
||||
return n, nil
|
||||
}
|
||||
return exch.rw.Read(dst)
|
||||
}
|
||||
|
||||
func (exch *Exchange) remainingSurplusBody() ([]byte, error) {
|
||||
_, err := exch.reqHdr.Body()
|
||||
if err != nil {
|
||||
return nil, err // Returns mangled buffer error if request header has been misused.
|
||||
}
|
||||
surplus := exch.rawbuf[exch.reqHdr.BufferParsed():exch.reqHdr.BufferReceived()]
|
||||
toRead := surplus[len(surplus)-exch.respRemains:]
|
||||
return toRead, nil
|
||||
}
|
||||
|
||||
// MuxPattern returns the pattern [Mux] matched to the request.
|
||||
func (exch *Exchange) MuxPattern() string {
|
||||
return exch.matchedPattern
|
||||
}
|
||||
|
||||
// RequestParseCookie parses the request's key header field into dst, i.e:
|
||||
// "Cookie". The caller owns dst and its buffer, so it may be reused between
|
||||
// requests.
|
||||
func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error {
|
||||
value := exch.RequestHeader(key)
|
||||
return dst.ParseBytes(value)
|
||||
}
|
||||
|
||||
// RequestContentType returns the request's Content-Type field value as it
|
||||
// appears on the wire, parameters included, nil if absent. Test it with
|
||||
// [httpraw.MediaTypeIs] and pick parameters out with [httpraw.ContentParam].
|
||||
func (exch *Exchange) RequestContentType() []byte {
|
||||
// Folded: field names are case insensitive and HTTP/2 mandates lowercase, so
|
||||
// a proxy translating h2 to h1 sends "content-type", RFC 9110 5.1.
|
||||
return exch.RequestHeaderV1Raw().GetFold("Content-Type")
|
||||
}
|
||||
|
||||
// RequestContentLength returns the body length declared by the request's
|
||||
// Content-Length field. An absent field is signalled with present=false and no error.
|
||||
// See [httpraw.HeaderV1.ContentLength].
|
||||
func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) {
|
||||
return exch.RequestHeaderV1Raw().ContentLength()
|
||||
}
|
||||
|
||||
// RequestParseForm parses "application/x-www-form-urlencoded" pairs into dst
|
||||
// from the request body and, when parseURL is set, from the query string as
|
||||
// well. Pairs are stored as they arrived, call [httpraw.Form.Decode] to decode
|
||||
// them in place.
|
||||
//
|
||||
// dst owns the memory: both sources are read into its buffer and parsed together
|
||||
// once. Hand it a preallocated buffer with [httpraw.Form.Reset] and turn growth
|
||||
// off with [httpraw.Form.EnableBufferGrowth] to bound it, which then reports
|
||||
// [httpraw.ErrBufferExhausted] instead of allocating. It grows by default.
|
||||
//
|
||||
// prioritizeURL reads the query ahead of the body, so a key carried by both
|
||||
// resolves to the query's value: [httpraw.Form.Get] answers with the first pair
|
||||
// holding a key. Both stay readable in wire order through [httpraw.Form.Pair].
|
||||
// The body is consumed, so call this before [Exchange.ReadBody].
|
||||
//
|
||||
// A request with no Content-Length has no body, RFC 9112 6.3, and one with no
|
||||
// Content-Type declares no encoding to parse, RFC 9110 8.3. Neither is an error,
|
||||
// a bodiless POST being legal, and the query is still parsed when asked for. A
|
||||
// Content-Type that is present and not form encoded is [errNotFormEncoded].
|
||||
func (exch *Exchange) RequestParseForm(dst *httpraw.Form, parseURL, prioritizeURL bool) error {
|
||||
dst.Reset(nil, 0) // Reuse whatever buffer dst holds, discarding old pairs.
|
||||
if parseURL && prioritizeURL {
|
||||
if err := exch.readQueryForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := exch.readBodyForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if parseURL && !prioritizeURL {
|
||||
if err := exch.readQueryForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return dst.Parse()
|
||||
}
|
||||
|
||||
// formSeparator joins two sources inside one form buffer. Shared so appending it
|
||||
// converts no literal per call.
|
||||
var formSeparator = []byte{'&'}
|
||||
|
||||
// readQueryForm appends the request's query string to dst's buffer.
|
||||
func (exch *Exchange) readQueryForm(dst *httpraw.Form) error {
|
||||
query := exch.RequestQuery()
|
||||
if len(query) == 0 {
|
||||
return nil
|
||||
} else if err := separateForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return dst.ReadFromBytes(query)
|
||||
}
|
||||
|
||||
// readBodyForm appends the request body to dst's buffer, reading until
|
||||
// Content-Length bytes have arrived.
|
||||
func (exch *Exchange) readBodyForm(dst *httpraw.Form) error {
|
||||
contentType := exch.RequestContentType()
|
||||
if contentType == nil {
|
||||
return nil // No declared encoding is no form, RFC 9110 8.3.
|
||||
} else if !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
|
||||
return errNotFormEncoded
|
||||
} else if exch.RequestHeaderV1Raw().GetFold("Transfer-Encoding") != nil {
|
||||
// Chunked bodies are framed, so reading Content-Length bytes off the
|
||||
// wire would parse chunk sizes as form data. httpraw does not decode them.
|
||||
return errUnsupportedTransferCoding
|
||||
}
|
||||
length, present, err := exch.RequestContentLength()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !present || length == 0 {
|
||||
return nil // No length is no body, RFC 9112 6.3.
|
||||
}
|
||||
if err = separateForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reuse the exchange's own handle: a local would escape when boxed into the
|
||||
// io.Reader [httpraw.Form.ReadLimited] takes, costing an allocation a request.
|
||||
exch.ReadWriter(&exch.bodyRW)
|
||||
// A single read may fall short of the limit, the body arriving a TCP segment
|
||||
// at a time, so read until the declared length is in hand.
|
||||
for read := 0; read < int(length); {
|
||||
n, err := dst.ReadLimited(&exch.bodyRW, int(length)-read)
|
||||
read += n
|
||||
if n == 0 {
|
||||
if err == nil {
|
||||
err = io.ErrNoProgress
|
||||
} else if err == io.EOF {
|
||||
break // Peer sent less than it declared.
|
||||
}
|
||||
return err
|
||||
} else if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// separateForm appends the '&' keeping two sources from merging into one pair,
|
||||
// doing nothing while dst holds no bytes yet.
|
||||
func separateForm(dst *httpraw.Form) error {
|
||||
if dst.BufferUsed() == 0 {
|
||||
return nil
|
||||
}
|
||||
return dst.ReadFromBytes(formSeparator)
|
||||
}
|
||||
|
||||
// RequestMultipart returns a parser prepared from the boundary parameter of the
|
||||
// request's Content-Type field. It reads no body: multipart parts declare no
|
||||
// length, so the caller drives the loop with a buffer it owns and decides per
|
||||
// part what to keep and when a part has grown too large. See
|
||||
// [Exchange.ReadMultiparts] for that loop already written.
|
||||
func (exch *Exchange) RequestMultipart() (mp httpraw.Multipart, err error) {
|
||||
contentType := exch.RequestContentType()
|
||||
if !httpraw.MediaTypeIs(contentType, "multipart/form-data") {
|
||||
return mp, errNotMultipart
|
||||
}
|
||||
return mp, mp.SetContentType(contentType)
|
||||
}
|
||||
|
||||
// MultipartSink is a part of a multipart body together with the writer its
|
||||
// content was streamed to, as appended by [Exchange.ReadMultiparts].
|
||||
type MultipartSink struct {
|
||||
// Header identifies the part. Name and Filename are copies, so they
|
||||
// outlive the read buffer; PartView does not, see [httpraw.MultipartHeader].
|
||||
Header httpraw.MultipartHeader
|
||||
// Sink received the part's content and was closed when the part ended,
|
||||
// nil for a part newSink chose to discard.
|
||||
Sink io.WriteCloser
|
||||
}
|
||||
|
||||
// ReadMultiparts streams the request's "multipart/form-data" body, writing each
|
||||
// part to a sink newSink returns for it and appending the pair to dst. buf is the
|
||||
// only storage used and content is never held whole, so a part of any length
|
||||
// streams through a buffer the caller sized. dst is appended to and returned, so
|
||||
// a handler may hand back the slice of a previous request to reuse its parts.
|
||||
//
|
||||
// newSink is called once per part, before any of its content is read, and picks
|
||||
// what to do with it from hdr.Name and hdr.Filename: return a writer to keep the
|
||||
// part, or nil to discard its content and keep only the header. Each sink is
|
||||
// closed as soon as its part ends, so Close reports the part arrived whole; on
|
||||
// error the sink of the part being read is left open for the caller to deal with.
|
||||
//
|
||||
// A part header that does not fit buf is refused with [lneto.ErrShortBuffer],
|
||||
// since reading more can never complete it, leaving the caller free to answer
|
||||
// 413. The body is consumed, so call this before [Exchange.ReadBody].
|
||||
func (exch *Exchange) ReadMultiparts(dst []MultipartSink, buf []byte, newSink func(hdr *httpraw.MultipartHeader) io.WriteCloser) (_ []MultipartSink, _ error) {
|
||||
mp, err := exch.RequestMultipart()
|
||||
if err != nil {
|
||||
return dst, err
|
||||
} else if newSink == nil || len(buf) <= len("\r\n--")+len(mp.Boundary) {
|
||||
// A buffer that cannot outgrow a delimiter never makes progress.
|
||||
return dst, lneto.ErrInvalidConfig
|
||||
}
|
||||
buflen := 0
|
||||
for {
|
||||
// Slot for the next part, given back when the body turns out to be
|
||||
// over, so its Name and Filename buffers stay available for reuse.
|
||||
part := internal.SliceReclaim(&dst)
|
||||
var parsed int
|
||||
for {
|
||||
parsed, err = mp.NextHeader(&part.Header, buf[:buflen])
|
||||
if err != nil {
|
||||
dst = dst[:len(dst)-1]
|
||||
if err == io.EOF {
|
||||
err = nil // Closing delimiter, body done.
|
||||
}
|
||||
return dst, err
|
||||
} else if parsed > 0 {
|
||||
break // Delimiter and header block complete.
|
||||
} else if buflen == len(buf) {
|
||||
dst = dst[:len(dst)-1]
|
||||
return dst, lneto.ErrShortBuffer // Header longer than buf.
|
||||
}
|
||||
// A read that both delivers and fails, as the last of the body
|
||||
// followed by a hangup does, may still hold what the parser is
|
||||
// waiting for: take the data and let the error surface on the
|
||||
// next read.
|
||||
n, readErr := exch.ReadBody(buf[buflen:])
|
||||
buflen += n
|
||||
if n == 0 && readErr != nil {
|
||||
dst = dst[:len(dst)-1]
|
||||
return dst, readErr
|
||||
}
|
||||
}
|
||||
part.Sink = newSink(&part.Header)
|
||||
buflen = copy(buf, buf[parsed:buflen])
|
||||
for {
|
||||
bodyLen, restOff, done := mp.NextBody(buf[:buflen])
|
||||
if bodyLen > 0 && part.Sink != nil {
|
||||
_, err = part.Sink.Write(buf[:bodyLen])
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
}
|
||||
buflen = copy(buf, buf[restOff:buflen])
|
||||
if done {
|
||||
break // Buffer now starts at the next part's delimiter.
|
||||
}
|
||||
n, readErr := exch.ReadBody(buf[buflen:])
|
||||
buflen += n
|
||||
if n == 0 && readErr != nil {
|
||||
return dst, readErr // Body ended mid part.
|
||||
}
|
||||
}
|
||||
if part.Sink != nil {
|
||||
if err = part.Sink.Close(); err != nil {
|
||||
return dst, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestHeader returns the value of the first request header field matching
|
||||
// key, or nil if absent. Key matching is case sensitive.
|
||||
func (exch *Exchange) RequestHeader(key string) []byte {
|
||||
header := exch.RequestHeaderV1Raw()
|
||||
return header.Get(key)
|
||||
}
|
||||
|
||||
// RequestTarget returns the request-target (URI) of the request line, i.e:
|
||||
// "/search?q=go". See [httpraw.HeaderV1.RequestTarget].
|
||||
func (exch *Exchange) RequestTarget() []byte {
|
||||
return exch.RequestHeaderV1Raw().RequestTarget()
|
||||
}
|
||||
|
||||
// RequestPath returns the request-target (URI) up to the query string. This is
|
||||
// what the [Mux] matches on, i.e: "/search" for a request to "/search?q=go".
|
||||
func (exch *Exchange) RequestPath() []byte {
|
||||
return exch.RequestHeaderV1Raw().RequestPath()
|
||||
}
|
||||
|
||||
// RequestQuery returns the request's query string as it appears on the wire.
|
||||
// Iterate it with [httpraw.NextQueryPair]. See [httpraw.HeaderV1.RequestQuery].
|
||||
func (exch *Exchange) RequestQuery() []byte {
|
||||
return exch.RequestHeaderV1Raw().RequestQuery()
|
||||
}
|
||||
|
||||
// RequestQueryValue returns an undecoded view of the first query parameter
|
||||
// matching key and reports whether it was present. Keys are matched decoded, so
|
||||
// key "a b" finds "a%20b" and "a+b"; a parameter whose key is a malformed
|
||||
// escape is skipped. A parameter with no value ("?debug") and one with an empty
|
||||
// value ("?debug=") are both present with a zero length view.
|
||||
//
|
||||
// The view aliases the request buffer, so copy it to outlive the handler or use
|
||||
// [Exchange.RequestQueryAppend] to decode it out.
|
||||
func (exch *Exchange) RequestQueryValue(key string) (rawValue []byte, present bool) {
|
||||
const plusAsSpace = true // Query strings are form encoded, unlike paths.
|
||||
rawkey, rawval, rest := httpraw.NextQueryPair(exch.RequestQuery())
|
||||
for ; rawkey != nil; rawkey, rawval, rest = httpraw.NextQueryPair(rest) {
|
||||
// Compare raw first: a key needing no decoding is the common case, and
|
||||
// the decoding compare walks the key an escape at a time.
|
||||
if b2s(rawkey) == key || httpraw.EqualDecodedPercentURL(rawkey, key, plusAsSpace) {
|
||||
return rawval, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// RequestQueryAppend appends the value of the first query parameter matching key to
|
||||
// dst and reports whether the parameter was present, matching keys as
|
||||
// [Exchange.RequestQueryValue] does. A parameter with no value ("?debug") and
|
||||
// one with an empty value ("?debug=") are both present with nothing appended.
|
||||
//
|
||||
// Values are appended raw unless decoded is set, in which case percent escapes
|
||||
// and '+' are decoded. A parameter whose value fails to decode is reported
|
||||
// absent, dst being left as it was rather than holding half a decode.
|
||||
func (exch *Exchange) RequestQueryAppend(dst []byte, key string, decoded bool) (valueAppended []byte, present bool) {
|
||||
const plusAsSpace = true // Query strings are form encoded, unlike paths.
|
||||
rawval, present := exch.RequestQueryValue(key)
|
||||
if !present || len(rawval) == 0 {
|
||||
return dst, present
|
||||
}
|
||||
if !decoded {
|
||||
return append(dst, rawval...), true
|
||||
}
|
||||
base := len(dst)
|
||||
dst = slices.Grow(dst, len(rawval))
|
||||
n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawval)], rawval, plusAsSpace)
|
||||
if err != nil {
|
||||
return dst[:base], false // Do not hand back half a decode.
|
||||
}
|
||||
return dst[:base+n], true
|
||||
}
|
||||
|
||||
// PathValue returns the segment the request path bound to the wildcard named
|
||||
// key, or nil if the matched pattern has no such wildcard. It plays the part of
|
||||
// http.Request.PathValue. See [SetPathValues] for the pattern syntax and for
|
||||
// which segments a wildcard binds.
|
||||
//
|
||||
// sm.Handle("GET /users/{id}", func(exch *httphi.Exchange) {
|
||||
// id := exch.PathValue("id") // "42" on a GET /users/42.
|
||||
// })
|
||||
func (exch *Exchange) PathValue(key string) []byte {
|
||||
for i := range exch.pathValues {
|
||||
if exch.pathValues[i].Key == key {
|
||||
return exch.pathValues[i].Value
|
||||
} else if exch.pathValues[i].Key == "" {
|
||||
break // No more keys set.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PathValueAppend acceses the result of [Exchange.PathValue] and appends it to dst.
|
||||
// If decoded is set to true the result will be URL-percent decoded. An error is returned if URL-percent decoding fails.
|
||||
func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]byte, error) {
|
||||
const plusAsSpace = true
|
||||
rawValue := exch.PathValue(key)
|
||||
if !decoded || len(rawValue) == 0 {
|
||||
return append(dst, rawValue...), nil
|
||||
}
|
||||
base := len(dst)
|
||||
dst = slices.Grow(dst, len(rawValue))
|
||||
n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawValue)], rawValue, plusAsSpace)
|
||||
if err != nil {
|
||||
return dst[:base], err // Do not hand back half a decode.
|
||||
}
|
||||
return dst[:base+n], nil
|
||||
}
|
||||
|
||||
// RequestMethod returns the request's [Method] enum.
|
||||
func (exch *Exchange) RequestMethod() Method {
|
||||
return MethodFromBytes(exch.RequestMethodRaw())
|
||||
}
|
||||
|
||||
// RequestMethod returns the request line's method as a []byte view, i.e: "GET".
|
||||
func (exch *Exchange) RequestMethodRaw() []byte {
|
||||
return exch.RequestHeaderV1Raw().Method()
|
||||
}
|
||||
|
||||
// RequestConnectionClose returns true if the client asked for the connection to
|
||||
// be closed after this exchange with a "Connection: close" header field.
|
||||
func (exch *Exchange) RequestConnectionClose() bool {
|
||||
return exch.RequestHeaderV1Raw().ConnectionClose()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
)
|
||||
|
||||
// Fuzz targets in this file are written so that a stored corpus keeps its
|
||||
// meaning as the tests grow. Go's corpus files are positional and typed, so an
|
||||
// input is only reproducible while the code that decodes it stays fixed. Five
|
||||
// rules keep that true, and edits to this file must obey them:
|
||||
//
|
||||
// 1. A target's signature is frozen: func(t *testing.T, ctrl uint64, data []byte).
|
||||
// Adding, removing or reordering a parameter invalidates every stored entry.
|
||||
// 2. Control decisions come from ctrl only, wire bytes from data only. Never
|
||||
// branch on data, never put payload in ctrl: the two axes mutate apart.
|
||||
// 3. ctrl is a bit field read through absolute shifts and masks named below.
|
||||
// New knobs claim unused high bits and are only ever appended, never
|
||||
// renumbered, and the zero value of a knob must decode to the behaviour that
|
||||
// existed before it was added, so old entries keep replaying as they did.
|
||||
// 4. No PRNG, no cursor. No rand, no internal.Prand64, and no helper that
|
||||
// "reads the next N bits" while advancing a position: inserting one draw
|
||||
// ahead of another shifts every later decision, which is the same corpus
|
||||
// invalidation a PRNG causes.
|
||||
// 5. Nothing ambient: no wall clock, no goroutines, no map iteration order.
|
||||
// Targets drive [Handle] on the calling goroutine; [Router] is not fuzzed
|
||||
// here precisely because it serves on goroutines of its own.
|
||||
const (
|
||||
// Bits 0..3 index segSizes: how the request is split across reads.
|
||||
ctlSegShift, ctlSegMask = 0, 0xf
|
||||
// Bits 4..7 and 8..11 size the request and response halves of the buffer.
|
||||
ctlReqBufShift, ctlRespBufShift, ctlBufMask = 4, 8, 0xf
|
||||
// Bits 12..13 size the request header field table.
|
||||
ctlKVCapShift, ctlKVCapMask = 12, 0x3
|
||||
// Bit 14 normalizes outgoing header keys.
|
||||
ctlNormalizeShift, ctlNormalizeMask = 14, 0x1
|
||||
// Bits 15..16 make that many leading writes to the connection fail.
|
||||
ctlFailWriteShift, ctlFailWriteMask = 15, 0x3
|
||||
// Bits 17..20 select what the handler does, see the op constants.
|
||||
ctlHandlerOpsShift, ctlHandlerOpsMask = 17, 0xf
|
||||
// Bits 21..23 count the header fields the handler stages.
|
||||
ctlStageCountShift, ctlStageCountMask = 21, 0x7
|
||||
// Bit 24 asks AppendQuery for decoded values.
|
||||
ctlDecodeShift, ctlDecodeMask = 24, 0x1
|
||||
// Bits 25..28 size the scratch buffer handed to form and multipart parsing.
|
||||
ctlScratchShift, ctlScratchMask = 25, 0xf
|
||||
// Bit 29 makes the multipart sink discard part content.
|
||||
ctlDiscardShift, ctlDiscardMask = 29, 0x1
|
||||
// Next knob starts at bit 30.
|
||||
)
|
||||
|
||||
// Handler operations, selected by the ctlHandlerOps field. Values are frozen:
|
||||
// a new operation takes the next free bit within the field.
|
||||
const (
|
||||
opStageHeaders = 1 << iota
|
||||
opReadBody
|
||||
opWriteBody
|
||||
opHijack
|
||||
)
|
||||
|
||||
// segSizes are the chunk sizes a request may be delivered in, index 0 meaning
|
||||
// "all at once". Splitting a request mid-CRLF or before a colon is what drives
|
||||
// [httpraw.HeaderV1.TryParse]'s resumption path. Entries may be appended, never
|
||||
// changed: an existing index must keep splitting exactly as it does today.
|
||||
var segSizes = [16]int{0, 1, 2, 3, 5, 7, 11, 16, 23, 37, 64, 101, 173, 256, 509, 1024}
|
||||
|
||||
// ctlField reads a knob out of ctrl. Absolute shift, no cursor, so knobs are
|
||||
// independent of one another and of the order they are read in.
|
||||
func ctlField(ctrl uint64, shift, mask uint64) uint64 {
|
||||
return (ctrl >> shift) & mask
|
||||
}
|
||||
|
||||
// maxFuzzInput bounds the wire data a target accepts. The filter depends only
|
||||
// on the input, so it classifies an entry the same way on every run.
|
||||
const maxFuzzInput = 16 << 10
|
||||
|
||||
// fuzzExchange returns an exchange acquired on a connection preloaded with data,
|
||||
// both sized and segmented by ctrl.
|
||||
func fuzzExchange(t *testing.T, ctrl uint64, data []byte) (*Exchange, *rwconn) {
|
||||
t.Helper()
|
||||
reqBuf := minRequestHeaderBuffer + int(ctlField(ctrl, ctlReqBufShift, ctlBufMask))*8
|
||||
respBuf := minResponseHeaderBuffer + int(ctlField(ctrl, ctlRespBufShift, ctlBufMask))*8
|
||||
kvCap := 1 + int(ctlField(ctrl, ctlKVCapShift, ctlKVCapMask))*8
|
||||
|
||||
conn := newConn("")
|
||||
seg := segSizes[ctlField(ctrl, ctlSegShift, ctlSegMask)]
|
||||
if seg <= 0 || seg >= len(data) {
|
||||
conn.AddReadable(data)
|
||||
} else {
|
||||
for off := 0; off < len(data); off += seg {
|
||||
conn.AddSegment(string(data[off:min(off+seg, len(data))]))
|
||||
}
|
||||
}
|
||||
// Always hang up: a drained connection that never reports EOF reads (0,nil)
|
||||
// forever and [Handle] would back off in an unbounded loop.
|
||||
conn.Hangup()
|
||||
if n := ctlField(ctrl, ctlFailWriteShift, ctlFailWriteMask); n > 0 {
|
||||
conn.FailWrites(int(n))
|
||||
}
|
||||
exch := newExchange(t, conn, ExchangeConfig{
|
||||
RawBuf: make([]byte, reqBuf+respBuf),
|
||||
RequestBufferLim: reqBuf,
|
||||
NumHeaderKVCap: kvCap,
|
||||
NormalizeOutgoingKeys: ctlField(ctrl, ctlNormalizeShift, ctlNormalizeMask) != 0,
|
||||
NoRequestBufferGrowth: true,
|
||||
})
|
||||
return exch, conn
|
||||
}
|
||||
|
||||
// checkRequestView asserts the request-target views agree with one another.
|
||||
func checkRequestView(t *testing.T, exch *Exchange) {
|
||||
t.Helper()
|
||||
target, path, query := exch.RequestTarget(), exch.RequestPath(), exch.RequestQuery()
|
||||
if !bytes.HasPrefix(target, path) {
|
||||
t.Fatalf("path %q is not a prefix of target %q", path, target)
|
||||
}
|
||||
if len(query) > 0 && !bytes.HasSuffix(target, query) {
|
||||
t.Fatalf("query %q is not a suffix of target %q", query, target)
|
||||
}
|
||||
if len(path)+len(query) > len(target) {
|
||||
t.Fatalf("path %q and query %q exceed target %q", path, query, target)
|
||||
}
|
||||
}
|
||||
|
||||
// checkResponse asserts that whatever reached the wire is a response a peer
|
||||
// could parse: a status line this package produced, followed by a header block
|
||||
// that terminates, and that [httpraw] reads back what it wrote.
|
||||
func checkResponse(t *testing.T, written string) {
|
||||
t.Helper()
|
||||
if written == "" {
|
||||
return // Hijacked, or a request refused before anything was staged.
|
||||
}
|
||||
const proto = "HTTP/1.1 "
|
||||
if !strings.HasPrefix(written, proto) {
|
||||
t.Fatalf("response does not open with a status line: %q", written)
|
||||
}
|
||||
if len(written) < len(proto)+4 {
|
||||
t.Fatalf("status line truncated: %q", written)
|
||||
}
|
||||
for _, c := range []byte(written[len(proto) : len(proto)+3]) {
|
||||
if c < '0' || c > '9' {
|
||||
t.Fatalf("status code is not three digits: %q", written)
|
||||
}
|
||||
}
|
||||
if written[len(proto)+3] != ' ' {
|
||||
t.Fatalf("status code not followed by a space: %q", written)
|
||||
}
|
||||
if !strings.Contains(written, "\r\n\r\n") {
|
||||
t.Fatalf("header block never terminated: %q", written)
|
||||
}
|
||||
var resp httpraw.HeaderV1
|
||||
const asResponse = true
|
||||
if err := resp.ParseBytes(asResponse, []byte(written)); err != nil {
|
||||
t.Fatalf("response does not parse back: %s in %q", err, written)
|
||||
}
|
||||
}
|
||||
|
||||
// handler2Path is a second registration so lookup does not always match on the
|
||||
// first entry of the mux.
|
||||
const handler2Path = "/fuzz"
|
||||
|
||||
// fuzzMux returns a mux serving handler on the paths the seed corpus requests.
|
||||
func fuzzMux(handler HandlerFunc) *MuxSlice {
|
||||
var mux MuxSlice
|
||||
mux.Handle("/", handler)
|
||||
mux.Handle(handler2Path, handler)
|
||||
return &mux
|
||||
}
|
||||
|
||||
// FuzzHandleRequest drives a whole exchange: a request off the wire through
|
||||
// [Handle], a handler staging and writing a response, and back out to the peer.
|
||||
func FuzzHandleRequest(f *testing.F) {
|
||||
addSeeds(f)
|
||||
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
||||
if len(data) > maxFuzzInput {
|
||||
return
|
||||
}
|
||||
exch, conn := fuzzExchange(t, ctrl, data)
|
||||
ops := ctlField(ctrl, ctlHandlerOpsShift, ctlHandlerOpsMask)
|
||||
stage := ctlField(ctrl, ctlStageCountShift, ctlStageCountMask)
|
||||
|
||||
Handle(exch, fuzzMux(func(exch *Exchange) {
|
||||
checkRequestView(t, exch)
|
||||
if ops&opStageHeaders != 0 {
|
||||
// Literal fields: staging request bytes would test the caller's
|
||||
// escaping, not this package's framing.
|
||||
for i := range stage {
|
||||
exch.StageHeader("X-Fuzz", "value")
|
||||
exch.StageHeaderIntBase("X-Fuzz-Int", int64(i), 10)
|
||||
}
|
||||
}
|
||||
if ops&opReadBody != 0 {
|
||||
var body [64]byte
|
||||
for range 64 {
|
||||
n, err := exch.ReadBody(body[:])
|
||||
if n == 0 && err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ops&opWriteBody != 0 {
|
||||
exch.WriteBody([]byte("fuzz body"))
|
||||
}
|
||||
if ops&opHijack != 0 {
|
||||
exch.HijackRaw(nil)
|
||||
}
|
||||
}), nopBackoff)
|
||||
|
||||
if ctlField(ctrl, ctlFailWriteShift, ctlFailWriteMask) == 0 {
|
||||
// A refused write leaves a partial response on purpose, so the
|
||||
// wire is only well formed when every write got through.
|
||||
checkResponse(t, conn.ViewWritten())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzQueryAndForm drives the request-target query and the form-encoded body,
|
||||
// both of which decode percent escapes in place over caller memory.
|
||||
func FuzzQueryAndForm(f *testing.F) {
|
||||
addSeeds(f)
|
||||
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
||||
if len(data) > maxFuzzInput {
|
||||
return
|
||||
}
|
||||
exch, _ := fuzzExchange(t, ctrl, data)
|
||||
decoded := ctlField(ctrl, ctlDecodeShift, ctlDecodeMask) != 0
|
||||
scratchLen := 8 + int(ctlField(ctrl, ctlScratchShift, ctlScratchMask))*16
|
||||
|
||||
Handle(exch, fuzzMux(func(exch *Exchange) {
|
||||
// Every pair the iterator yields must be reachable by name, or the
|
||||
// two views of the query string disagree.
|
||||
const maxPairs = 64
|
||||
key, _, rest := httpraw.NextQueryPair(exch.RequestQuery())
|
||||
for pairs := 0; key != nil && pairs < maxPairs; pairs++ {
|
||||
dec := make([]byte, len(key))
|
||||
n, err := httpraw.CopyDecodedPercentURL(dec, key, true)
|
||||
if err == nil {
|
||||
if n > len(key) {
|
||||
t.Fatalf("decoding key %q grew it to %d bytes", key, n)
|
||||
}
|
||||
if _, present := exch.RequestQueryAppend(nil, string(dec[:n]), decoded); !present {
|
||||
t.Fatalf("query pair %q absent from AppendQuery", key)
|
||||
}
|
||||
}
|
||||
key, _, rest = httpraw.NextQueryPair(rest)
|
||||
}
|
||||
|
||||
var form httpraw.Form
|
||||
buf := make([]byte, scratchLen)
|
||||
if err := exch.RequestParseForm(&form, false, false); err != nil {
|
||||
return
|
||||
}
|
||||
total := 0
|
||||
lens := make([]int, form.Len())
|
||||
for i := range form.Len() {
|
||||
k, v := form.Pair(i)
|
||||
lens[i] = len(k) + len(v)
|
||||
total += lens[i]
|
||||
}
|
||||
if total > len(buf) {
|
||||
t.Fatalf("form pairs span %d bytes of a %d byte buffer", total, len(buf))
|
||||
}
|
||||
if err := form.Decode(); err != nil {
|
||||
return
|
||||
}
|
||||
// Decoding replaces escapes in place, so no pair may grow.
|
||||
for i := range form.Len() {
|
||||
k, v := form.Pair(i)
|
||||
if len(k)+len(v) > lens[i] {
|
||||
t.Fatalf("pair %d grew from %d to %d bytes on decode", i, lens[i], len(k)+len(v))
|
||||
}
|
||||
}
|
||||
}), nopBackoff)
|
||||
})
|
||||
}
|
||||
|
||||
// countSink counts what a multipart part streamed into it and whether the part
|
||||
// was closed off.
|
||||
type countSink struct {
|
||||
written int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *countSink) Write(b []byte) (int, error) {
|
||||
c.written += len(b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *countSink) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// FuzzMultipart drives [Exchange.ReadMultiparts], which streams a body of
|
||||
// unknown length through a buffer the caller sized.
|
||||
func FuzzMultipart(f *testing.F) {
|
||||
addSeeds(f)
|
||||
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
||||
if len(data) > maxFuzzInput {
|
||||
return
|
||||
}
|
||||
exch, _ := fuzzExchange(t, ctrl, data)
|
||||
discard := ctlField(ctrl, ctlDiscardShift, ctlDiscardMask) != 0
|
||||
bufLen := 16 + int(ctlField(ctrl, ctlScratchShift, ctlScratchMask))*16
|
||||
|
||||
Handle(exch, fuzzMux(func(exch *Exchange) {
|
||||
var sinks []*countSink
|
||||
_, err := exch.ReadMultiparts(nil, make([]byte, bufLen), func(hdr *httpraw.MultipartHeader) io.WriteCloser {
|
||||
if discard {
|
||||
return nil
|
||||
}
|
||||
sink := new(countSink)
|
||||
sinks = append(sinks, sink)
|
||||
return sink
|
||||
})
|
||||
if err != nil {
|
||||
return // Sinks are left for the caller to deal with on error.
|
||||
}
|
||||
total := 0
|
||||
for _, sink := range sinks {
|
||||
if !sink.closed {
|
||||
t.Fatal("ReadMultiparts returned with a part left open")
|
||||
}
|
||||
total += sink.written
|
||||
}
|
||||
if total > len(data) {
|
||||
t.Fatalf("parts streamed %d bytes out of a %d byte request", total, len(data))
|
||||
}
|
||||
}), nopBackoff)
|
||||
})
|
||||
}
|
||||
|
||||
// addSeeds adds the shared seed corpus. Every seed pins ctrl to zero so its
|
||||
// meaning never moves: knobs added later decode their zero value to the
|
||||
// behaviour the seed was recorded under.
|
||||
func addSeeds(f *testing.F) {
|
||||
f.Helper()
|
||||
const (
|
||||
formType = "Content-Type: application/x-www-form-urlencoded\r\n"
|
||||
mpType = "Content-Type: multipart/form-data; boundary=b0undary\r\n"
|
||||
)
|
||||
seeds := []string{
|
||||
// Well formed traffic, so the fuzzer has somewhere to mutate from.
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /fuzz?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3",
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 76\r\n\r\n--b0undary\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nbody\r\n--b0undary--\r\n",
|
||||
|
||||
// Framing the RFC leaves room to disagree over, which is where request
|
||||
// smuggling lives: two lengths, a length plus a coding, and a coding
|
||||
// this package does not decode.
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\nContent-Length: 4\r\n\r\nabcd",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\nTransfer-Encoding: chunked\r\n\r\n1\r\na\r\n0\r\n\r\n",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n",
|
||||
|
||||
// Field names are case insensitive, RFC 9110 5.1, so a lookup that
|
||||
// misses one of these reads the request differently than the peer wrote it.
|
||||
"POST / HTTP/1.1\r\nHost: h\r\ncontent-length: 4\r\n\r\nbody",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nCONTENT-LENGTH: 4\r\n\r\nbody",
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\ncontent-type: application/x-www-form-urlencoded\r\ncontent-length: 3\r\n\r\na=1",
|
||||
|
||||
// Content-Length values that are not a bare digit string, RFC 9112 6.2.
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: +5\r\n\r\nbody!",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: -1\r\n\r\nbody",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 1 2\r\n\r\nbody",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length:\r\n\r\nbody",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 9223372036854775808\r\n\r\nbody",
|
||||
|
||||
// Line ending and field syntax edges.
|
||||
"GET / HTTP/1.1\nHost: h\n\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\rX: y\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n Continued: fold\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\nX: va\x00lue\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\nNoColon\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n: novalue\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\nX:\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n\r\n\r\n",
|
||||
|
||||
// Request lines this package tolerates or must refuse.
|
||||
"GET http://h/abs HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /\r\n\r\n", // HTTP/0.9 simple request, no version.
|
||||
" GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"/ HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET / HTTP/9.9\r\nHost: h\r\n\r\n",
|
||||
|
||||
// Percent escapes, including the truncated and the malformed.
|
||||
"GET /a%20b?a%20b=c%20d HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /?q=%2 HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /?q=%zz HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /%00?a=%00 HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /?a=b=c&&=v&debug HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET /?a%3db=1&a+b=2 HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
|
||||
// Multipart bodies that end early or never open a part.
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 12\r\n\r\n--b0undary\r\n",
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 14\r\n\r\n--b0undary--\r\n",
|
||||
"POST /fuzz HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data\r\nContent-Length: 4\r\n\r\nbody",
|
||||
|
||||
// Sizes that crowd the buffers: a long target, many fields, and a body
|
||||
// arriving in the same read as the header it follows.
|
||||
"GET /" + strings.Repeat("a", 512) + " HTTP/1.1\r\nHost: h\r\n\r\n",
|
||||
"GET / HTTP/1.1\r\n" + strings.Repeat("X: y\r\n", 200) + "\r\n",
|
||||
"GET / HTTP/1.1\r\n" + strings.Repeat("k", 512) + ": v\r\n\r\n",
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 4\r\n\r\nbodyTRAILING",
|
||||
|
||||
// Nothing, and nothing that resembles a request at all.
|
||||
"",
|
||||
"\r\n\r\n",
|
||||
"\x00\x00\x00\x00",
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
f.Add(uint64(0), []byte(seed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// Handle is a extremely low-level HTTP handling method used internally in [Router].
|
||||
// Requires exchange to be acquired and configured. Will panic if any argument is nil.
|
||||
// Handle does not close the connection on any outcome: the caller owns it.
|
||||
// backoff can be set for dealing with non-blocking connections. If backoff set to nil
|
||||
// then a zero-length-read will result in Handle returning [io.ErrNoProgress].
|
||||
func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
|
||||
if !exch.acquired.Load() {
|
||||
return lneto.ErrBadState
|
||||
}
|
||||
reqhdr := &exch.reqHdr
|
||||
reqhdr.Reset(nil, 0) // Assume exchange has been configured and reuse memory.
|
||||
var consecutiveBackoffs uint
|
||||
for {
|
||||
n, err := reqhdr.ReadFromLimited(exch.rw, reqhdr.BufferFree())
|
||||
if err != nil {
|
||||
exch.readErr = err
|
||||
exch.handleError(err)
|
||||
return err
|
||||
} else if n == 0 {
|
||||
if backoff == nil {
|
||||
return io.ErrNoProgress
|
||||
}
|
||||
backoff.Do(consecutiveBackoffs)
|
||||
consecutiveBackoffs++
|
||||
continue
|
||||
}
|
||||
consecutiveBackoffs = 0
|
||||
const asRequest = false
|
||||
needMore, err := reqhdr.TryParse(asRequest)
|
||||
if needMore {
|
||||
continue // Request header split across reads, accumulate the rest.
|
||||
} else if err != nil {
|
||||
exch.handleError(err)
|
||||
return err
|
||||
}
|
||||
break // Done!
|
||||
}
|
||||
// Setup Exchange fields necessary for correct functioning.
|
||||
parsed := reqhdr.BufferParsed()
|
||||
exch.respRemains = reqhdr.BufferReceived() - parsed
|
||||
exch.respHeaderOff = uint16(parsed)
|
||||
exch.respHeaderLen = 0
|
||||
proto := b2s(reqhdr.Protocol())
|
||||
if len(proto) == 0 {
|
||||
// HTTP/0.9 not tolerated RFC 9112 3.
|
||||
exch.WriteHeader(int(StatusBadRequest))
|
||||
return errNoRequestProto
|
||||
} else if proto != "HTTP/1.1" && proto != "HTTP/1.0" {
|
||||
// RFC 9112 2.6.
|
||||
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
|
||||
return errBadRequestProto
|
||||
}
|
||||
// Mux on the request path: the query string is the handler's business.
|
||||
path := reqhdr.RequestPath()
|
||||
meth := reqhdr.Method()
|
||||
clear(exch.pathValues)
|
||||
matchedPattern, handler := mux.LookupHandler(MethodFromBytes(meth), path, exch.pathValues)
|
||||
if handler != nil {
|
||||
exch.matchedPattern = matchedPattern
|
||||
handler(exch)
|
||||
if !exch.hijacked {
|
||||
exch.FlushHeader()
|
||||
}
|
||||
} else {
|
||||
exch.WriteHeader(404)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (exch *Exchange) handleError(err error) {
|
||||
if err == lneto.ErrUnsupported {
|
||||
// httpraw refused a first line naming a version it does not speak, before
|
||||
// spending the field loop on it. An empty protocol is a HTTP/0.9
|
||||
// simple-request, RFC 9112 3: a malformed 1.x request-line rather than a
|
||||
// version there is any point naming back.
|
||||
if len(exch.reqHdr.Protocol()) == 0 {
|
||||
exch.WriteHeader(int(StatusBadRequest))
|
||||
} else {
|
||||
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == httpraw.ErrHeaderTooMany || err == httpraw.ErrBufferExhausted || exch.reqHdr.BufferFree() == 0 {
|
||||
// The peer is owed an answer: no larger buffer is coming, so
|
||||
// say so instead of dropping the connection, RFC 6585 5.
|
||||
exch.StageHeader("Content-Length", "0")
|
||||
exch.WriteHeader(int(StatusRequestHeaderFieldsTooLarge))
|
||||
}
|
||||
}
|
||||
|
||||
// HandlerFunc serves a single request, playing the part of http.Handler.
|
||||
// The exchange is only valid for the duration of the call: it is released to
|
||||
// the router's pool on return, so a handler must not retain it nor any slice it
|
||||
// handed out.
|
||||
type HandlerFunc func(ex *Exchange)
|
||||
|
||||
// Mux resolves a request to the handler that serves it. [Handle] calls
|
||||
// LookupHandler with the request-target's path, not the whole target, and
|
||||
// replies 404 when it returns nil.
|
||||
type Mux interface {
|
||||
// LookupHandler matches the requestPath and method to a handler and returns it and the
|
||||
// pattern it matched. dstPathVals are set to non-zero values by Mux and can later be accessed by [Exchange.PathValue]
|
||||
// requestPath is a buffer owned by the [Exchange] usually and should not be held after LookupHandler returns.
|
||||
LookupHandler(get Method, requestPath []byte, dstPathVals []PathValue) (matchedPattern string, handler HandlerFunc)
|
||||
// MaxPathValues specifies the required size of dstPathVals in a call to [Mux.LookupHandler].
|
||||
// MaxPathValues should return -1 if no paths have been configured to catch situation
|
||||
// where the Mux has been passed to a [Router.Configuration] before registering paths.
|
||||
MaxPathValues() int
|
||||
}
|
||||
|
||||
// PathValue used to implement [Mux] interface. Stores http.Request.PathValue-like values.
|
||||
type PathValue struct {
|
||||
Key string // owned by mux.
|
||||
Value []byte // points to raw exchange buffer.
|
||||
}
|
||||
|
||||
// pathSeparator is shared so [SetPathValues] never converts a literal per call.
|
||||
var pathSeparator = []byte{'/'}
|
||||
|
||||
// SetPathValues matches requestPath against pattern and binds its wildcards
|
||||
// into dstPathVals, read back with [Exchange.PathValue]. Wildcards are whole
|
||||
// segments as per http.ServeMux: "{name}" takes one non-empty segment,
|
||||
// "{name...}" the rest including slashes, "{$}" only the path's end, and a
|
||||
// trailing slash is an anonymous "{...}". i.e: "/b/{bucket}/o/{obj...}".
|
||||
//
|
||||
// Unlike ServeMux, segments are compared and bound raw, so "/users/{id}" binds
|
||||
// "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias
|
||||
// requestPath rather than copy it.
|
||||
//
|
||||
// Values are bound while walking, before the match is known, so on failure
|
||||
// SetPathValues clears what it bound. A [Mux] may then try patterns in turn
|
||||
// without a matching one inheriting values from one that failed.
|
||||
func SetPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) {
|
||||
n, matched, pathValSliceTooShort := setPathValues(dstPathVals, pattern, requestPath)
|
||||
if !matched {
|
||||
clear(dstPathVals[:n])
|
||||
}
|
||||
return matched, pathValSliceTooShort
|
||||
}
|
||||
|
||||
// setPathValues is [SetPathValues] reporting how many values it bound, so its
|
||||
// caller can discard them when the pattern turns out not to match.
|
||||
func setPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (n int, matched, pathValSliceTooShort bool) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' {
|
||||
return n, false, false
|
||||
}
|
||||
pattern, requestPath = pattern[1:], requestPath[1:]
|
||||
for {
|
||||
if len(pattern) == 0 {
|
||||
// Nothing left after a slash: an anonymous "..." taking the rest,
|
||||
// which is why "/files/" matches "/files/a/b" and "/" matches all.
|
||||
return n, true, false
|
||||
}
|
||||
patSeg, patRest, patMore := strings.Cut(pattern, "/")
|
||||
reqSeg, reqRest, reqMore := bytes.Cut(requestPath, pathSeparator)
|
||||
name, isMulti, isWildcard := pathWildcard(patSeg)
|
||||
switch {
|
||||
case isWildcard && name == "$":
|
||||
// Matches the end of the path and nothing else, so it must be the
|
||||
// last segment of the pattern and leave no path behind.
|
||||
return n, !patMore && len(requestPath) == 0, false
|
||||
|
||||
case isWildcard && isMulti:
|
||||
// Takes the remainder including slashes, possibly empty.
|
||||
if name != "" {
|
||||
if n >= len(dstPathVals) {
|
||||
return n, false, true
|
||||
}
|
||||
dstPathVals[n] = PathValue{Key: name, Value: requestPath}
|
||||
n++
|
||||
}
|
||||
return n, true, false
|
||||
|
||||
case isWildcard:
|
||||
if len(reqSeg) == 0 {
|
||||
return n, false, false // One segment means a non-empty one.
|
||||
}
|
||||
if n >= len(dstPathVals) {
|
||||
return n, false, true
|
||||
}
|
||||
dstPathVals[n] = PathValue{Key: name, Value: reqSeg}
|
||||
n++
|
||||
|
||||
default:
|
||||
if b2s(reqSeg) != patSeg {
|
||||
return n, false, false
|
||||
}
|
||||
}
|
||||
if patMore != reqMore {
|
||||
// One side has a further segment and the other does not, so
|
||||
// "/health" misses "/health/" and "/files/" misses "/files".
|
||||
return n, false, false
|
||||
} else if !patMore {
|
||||
return n, true, false // Both spent on the same segment.
|
||||
}
|
||||
pattern, requestPath = patRest, reqRest
|
||||
}
|
||||
}
|
||||
|
||||
// pathWildcard picks apart a "{name}" or "{name...}" pattern segment. It
|
||||
// reports ok false for a literal segment, so "/b_{bucket}" is literal text and
|
||||
// not a wildcard, matching ServeMux's rule that wildcards be whole segments.
|
||||
func pathWildcard(segment string) (name string, isMulti, ok bool) {
|
||||
if len(segment) < 2 || segment[0] != '{' || segment[len(segment)-1] != '}' {
|
||||
return "", false, false
|
||||
}
|
||||
name = segment[1 : len(segment)-1]
|
||||
if rest, found := strings.CutSuffix(name, "..."); found {
|
||||
return rest, true, true
|
||||
}
|
||||
return name, false, true
|
||||
}
|
||||
|
||||
// MuxSlice is a [Mux] implementation backed by a slice of registered endpoints, matched by
|
||||
// exact path. Lookup is linear in the number of registrations.
|
||||
type MuxSlice struct {
|
||||
// TODO: binary search worth it?
|
||||
_handlers []struct {
|
||||
method Method
|
||||
path string
|
||||
handler HandlerFunc
|
||||
pathVals int
|
||||
spec int
|
||||
}
|
||||
}
|
||||
|
||||
// Reset discards all registered handlers, reusing the backing array and growing
|
||||
// it to fit capacity registrations.
|
||||
func (sm *MuxSlice) Reset(capacity int) {
|
||||
internal.SliceReuse(&sm._handlers, capacity)
|
||||
}
|
||||
|
||||
// LookupHandler returns the handler registered for request path, or nil if none
|
||||
// matches. The most specific matching registration wins, not the first, so the
|
||||
// catch-all "/" may be registered alongside the endpoints it backs without
|
||||
// shadowing them, as in http.ServeMux, see [patternSpecificity]. Registrations
|
||||
// of equal specificity are resolved in registration order.
|
||||
//
|
||||
// Every method this package does not name is [MethUnknown], so a request with an
|
||||
// extension method matches a bare-path registration and any registration naming
|
||||
// an extension method, whichever it names. Tell PROPFIND from MKCOL inside the
|
||||
// handler with [Exchange.RequestMethodRaw].
|
||||
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) {
|
||||
best := -1
|
||||
bestSpec := 0
|
||||
for i, endpoint := range sm._handlers {
|
||||
if endpoint.method != MethUndefined && endpoint.method != method {
|
||||
continue
|
||||
} else if best >= 0 && endpoint.spec <= bestSpec {
|
||||
continue // Cannot beat the incumbent, so do not pay to match it.
|
||||
}
|
||||
// Method matches. A pattern ending in '/' is a wildcard despite binding no
|
||||
// values: the trailing slash is an anonymous "{...}", so it must go
|
||||
// through the matcher and not a literal compare, see [SetPathValues].
|
||||
var ok bool
|
||||
if isWildcardPattern(endpoint.path) {
|
||||
// dstPathVals is scratch during the scan: a candidate that matches and
|
||||
// is then beaten, or one that is beaten and clears on failure, would
|
||||
// leave the winner's values wrong, so the winner is bound below.
|
||||
ok, _ = SetPathValues(dstPathVals, endpoint.path, path)
|
||||
} else {
|
||||
ok = b2s(path) == endpoint.path
|
||||
}
|
||||
if ok {
|
||||
best, bestSpec = i, endpoint.spec
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return "", nil
|
||||
}
|
||||
winner := sm._handlers[best]
|
||||
if isWildcardPattern(winner.path) {
|
||||
clear(dstPathVals) // The scan may have bound more values than the winner does.
|
||||
SetPathValues(dstPathVals, winner.path, path)
|
||||
}
|
||||
return winner.path, winner.handler
|
||||
}
|
||||
|
||||
// MaxPathValues returns the maximum number of path values any endpoint could have.
|
||||
func (sm *MuxSlice) MaxPathValues() (maxPathValues int) {
|
||||
if len(sm._handlers) == 0 {
|
||||
return -1 // Signal no handlers registered.
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
maxPathValues = max(maxPathValues, endpoint.pathVals)
|
||||
}
|
||||
return maxPathValues
|
||||
}
|
||||
|
||||
// Handle registers handler for reg, either a bare path matching any method or a
|
||||
// method and path separated by a space, i.e: "/health" or "GET /health".
|
||||
//
|
||||
// Handle panics on a registration that could never serve a request: a method
|
||||
// token carrying lowercase (methods are case sensitive and uppercase, RFC 9110
|
||||
// 9.1, so "Get" matches no GET request), a path not rooted at '/', or an exact
|
||||
// duplicate of an earlier registration, which the first one always shadows.
|
||||
// Registration is program startup, so a fault belongs there and not in a
|
||||
// permanent silent 404.
|
||||
func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
|
||||
method := MethUndefined
|
||||
methodOrURL, url, methodFound := strings.Cut(optMethodAndPath, " ")
|
||||
if methodFound {
|
||||
if hasLowerASCII(methodOrURL) {
|
||||
panic("httphi: method must be uppercase in registration " + optMethodAndPath)
|
||||
}
|
||||
method = MethodFrom(methodOrURL)
|
||||
} else {
|
||||
url = methodOrURL
|
||||
}
|
||||
if len(url) == 0 || url[0] != '/' {
|
||||
panic("httphi: path must begin with '/' in registration " + optMethodAndPath)
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
if endpoint.method == method && endpoint.path == url {
|
||||
if method == MethUnknown {
|
||||
// Two extension methods are both MethUnknown, so the second is
|
||||
// unreachable. Register one and branch in the handler, see
|
||||
// [MuxSlice.LookupHandler].
|
||||
panic("httphi: extension method already registered on path in " + optMethodAndPath)
|
||||
}
|
||||
panic("httphi: duplicate registration " + optMethodAndPath)
|
||||
}
|
||||
}
|
||||
v := internal.SliceReclaim(&sm._handlers)
|
||||
v.pathVals = countPathValues(url)
|
||||
v.spec = patternSpecificity(url)
|
||||
v.method = method
|
||||
v.path = url
|
||||
v.handler = handler
|
||||
}
|
||||
|
||||
// patternSpecificity scores how tightly pattern pins a path, letting
|
||||
// [MuxSlice.LookupHandler] prefer the most specific match over the first one
|
||||
// registered. A literal segment pins harder than a wildcard segment, and a
|
||||
// pattern left open at the end ("/", "/files/", "/{p...}") pins less than one
|
||||
// spent on the whole path, so "/cnt" outscores "/" and "/users/me" outscores
|
||||
// "/users/{id}". Scoring at registration keeps lookup to an integer compare.
|
||||
//
|
||||
// The score is a total order over patterns, which the subset relation is not:
|
||||
// neither of "/a/{x}/c" and "/a/b/{y}" is more specific than the other, and they
|
||||
// tie here where http.ServeMux rejects the pair as conflicting. A tie is settled
|
||||
// by registration order rather than by a panic.
|
||||
func patternSpecificity(pattern string) (spec int) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
return 0
|
||||
}
|
||||
pattern = pattern[1:]
|
||||
for {
|
||||
if len(pattern) == 0 {
|
||||
return spec // Nothing after a slash: an anonymous "{...}" taking the rest.
|
||||
}
|
||||
segment, rest, more := strings.Cut(pattern, "/")
|
||||
name, isMulti, isWildcard := pathWildcard(segment)
|
||||
switch {
|
||||
case isWildcard && name == "$":
|
||||
return spec + 1 // Ends the path, so nothing is left open.
|
||||
case isWildcard && isMulti:
|
||||
return spec // Takes the remainder, pinning nothing more.
|
||||
case isWildcard:
|
||||
spec++
|
||||
default:
|
||||
spec += 2
|
||||
}
|
||||
if !more {
|
||||
return spec + 1 // Spent on the last segment: the pattern is exact.
|
||||
}
|
||||
pattern = rest
|
||||
}
|
||||
}
|
||||
|
||||
// countPathValues is how many values pattern can bind, which is what sizes the
|
||||
// slice [SetPathValues] writes into. Only a named wildcard segment binds: "{$}"
|
||||
// marks the path's end, an anonymous "{...}" has no name to bind under, and a
|
||||
// brace inside a literal segment is not a wildcard at all.
|
||||
func countPathValues(pattern string) (n int) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
return 0
|
||||
}
|
||||
pattern = pattern[1:]
|
||||
for len(pattern) > 0 {
|
||||
segment, rest, more := strings.Cut(pattern, "/")
|
||||
if name, _, ok := pathWildcard(segment); ok && name != "" && name != "$" {
|
||||
n++
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
pattern = rest
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isWildcardPattern reports whether pattern must go through [SetPathValues]
|
||||
// rather than a literal comparison. Distinct from the value count: "{$}" and a
|
||||
// trailing slash match by walking segments while binding nothing.
|
||||
func isWildcardPattern(pattern string) bool {
|
||||
return strings.IndexByte(pattern, '{') >= 0 || strings.HasSuffix(pattern, "/")
|
||||
}
|
||||
|
||||
// hasLowerASCII reports whether s carries an ASCII lowercase letter, which a
|
||||
// method token registered by mistake ("Get") does and a legal extension method
|
||||
// ("PROPFIND") does not.
|
||||
func hasLowerASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 'a' && s[i] <= 'z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method is a HTTP request method, parsed by [MethodFrom].
|
||||
type Method uint8
|
||||
|
||||
const (
|
||||
MethUndefined Method = iota // undefined
|
||||
MethGet // GET
|
||||
// lol.
|
||||
MethHead // HEAD
|
||||
MethPost // POST
|
||||
MethPut // PUT
|
||||
// RFC 5789
|
||||
MethPatch // PATCH
|
||||
MethDelete // DELETE
|
||||
MethConnect // CONNECT
|
||||
MethOptions // OPTIONS
|
||||
MethTrace // TRACE
|
||||
MethUnknown // unknown
|
||||
)
|
||||
|
||||
// MethodFrom returns the [Method] matching meth, [MethUndefined] if meth is
|
||||
// empty and [MethUnknown] if it names a method this package does not know.
|
||||
// Comparison is case sensitive: methods are uppercase, RFC 9110 9.1.
|
||||
func MethodFrom(meth string) (res Method) {
|
||||
if len(meth) == 0 {
|
||||
return MethUndefined
|
||||
}
|
||||
switch meth {
|
||||
case "GET":
|
||||
res = MethGet
|
||||
case "HEAD":
|
||||
res = MethHead
|
||||
case "POST":
|
||||
res = MethPost
|
||||
case "PUT":
|
||||
res = MethPut
|
||||
case "PATCH":
|
||||
res = MethPatch
|
||||
case "DELETE":
|
||||
res = MethDelete
|
||||
case "CONNECT":
|
||||
res = MethConnect
|
||||
case "OPTIONS":
|
||||
res = MethOptions
|
||||
case "TRACE":
|
||||
res = MethTrace
|
||||
default:
|
||||
res = MethUnknown
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// MethodFromBytes is a [MethodFrom] wrapper with bytes argument instead of string.
|
||||
func MethodFromBytes(meth []byte) (res Method) {
|
||||
if len(meth) == 0 {
|
||||
return MethUndefined
|
||||
}
|
||||
return MethodFrom(b2s(meth))
|
||||
}
|
||||
|
||||
// b2s converts byte slice to a string without memory allocation.
|
||||
// See https://groups.google.com/forum/#!msg/Golang-Nuts/ENgbUzYvCuU/90yGx7GUAgAJ .
|
||||
func b2s(b []byte) string {
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SetPathValues must agree with net/http.ServeMux on which patterns match which
|
||||
// paths and what each wildcard binds to. Every case below was taken from a run
|
||||
// against a real ServeMux, so this table is an oracle, not a guess.
|
||||
//
|
||||
// The one documented deviation is percent-decoding: ServeMux unescapes segments
|
||||
// before matching and binding, this does not. See TestSetPathValuesEscaping.
|
||||
func TestSetPathValues(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
match bool
|
||||
want string // "name=value" pairs joined by "|", in bind order.
|
||||
}{
|
||||
// Single segment wildcard binds exactly one non-empty segment.
|
||||
{pattern: "/users/{id}", path: "/users/42", match: true, want: "id=42"},
|
||||
{pattern: "/users/{id}", path: "/users/42/x", match: false},
|
||||
{pattern: "/users/{id}", path: "/users/", match: false},
|
||||
{pattern: "/users/{id}", path: "/users", match: false},
|
||||
{pattern: "/users/{id}/edit", path: "/users/42/edit", match: true, want: "id=42"},
|
||||
{pattern: "/{a}/{b}", path: "/x/y", match: true, want: "a=x|b=y"},
|
||||
|
||||
// "..." swallows the remainder, slashes included, and may bind empty.
|
||||
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o/a/b/c", match: true, want: "bucket=bk|obj=a/b/c"},
|
||||
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o/", match: true, want: "bucket=bk|obj="},
|
||||
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o", match: false},
|
||||
{pattern: "/files/{p...}", path: "/files/", match: true, want: "p="},
|
||||
{pattern: "/files/{p...}", path: "/files", match: false},
|
||||
|
||||
// {$} matches only the end of the path.
|
||||
{pattern: "/{$}", path: "/", match: true},
|
||||
{pattern: "/{$}", path: "/x", match: false},
|
||||
{pattern: "/a/{$}", path: "/a/", match: true},
|
||||
{pattern: "/a/{$}", path: "/a", match: false},
|
||||
{pattern: "/a/{$}", path: "/a/b", match: false},
|
||||
|
||||
// A trailing slash is an anonymous "..." wildcard, binding nothing.
|
||||
{pattern: "/files/", path: "/files/a/b", match: true},
|
||||
{pattern: "/files/", path: "/files/", match: true},
|
||||
{pattern: "/files/", path: "/files", match: false},
|
||||
{pattern: "/", path: "/anything/at/all", match: true},
|
||||
|
||||
// Literal patterns match exactly, trailing slash included.
|
||||
{pattern: "/health", path: "/health", match: true},
|
||||
{pattern: "/health", path: "/health/", match: false},
|
||||
|
||||
// An empty segment never satisfies a single wildcard.
|
||||
{pattern: "/a/{x}/b", path: "/a//b", match: false},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
vals := make([]PathValue, 8)
|
||||
match, tooShort := SetPathValues(vals, test.pattern, []byte(test.path))
|
||||
if tooShort {
|
||||
t.Fatal("8 slots must be enough for these patterns")
|
||||
}
|
||||
if match != test.match {
|
||||
t.Fatalf("want match=%v, got %v", test.match, match)
|
||||
}
|
||||
if !match {
|
||||
return
|
||||
}
|
||||
if got := renderPathValues(vals); got != test.want {
|
||||
t.Errorf("want %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Bound values must alias the request path buffer rather than copy it: the
|
||||
// exchange owns that memory and a copy would allocate per request.
|
||||
func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
|
||||
path := []byte("/users/42/edit")
|
||||
vals := make([]PathValue, 4)
|
||||
match, _ := SetPathValues(vals, "/users/{id}/edit", path)
|
||||
if !match {
|
||||
t.Fatal("want match")
|
||||
}
|
||||
if string(vals[0].Value) != "42" {
|
||||
t.Fatalf("want id=42, got %q", vals[0].Value)
|
||||
}
|
||||
// Mutating the request buffer must show through the bound value.
|
||||
path[7] = '9'
|
||||
if string(vals[0].Value) != "92" {
|
||||
t.Errorf("value must alias the request buffer, got %q", vals[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// A destination too small to hold every wildcard must say so rather than bind a
|
||||
// partial set or write out of range.
|
||||
func TestSetPathValuesSliceTooShort(t *testing.T) {
|
||||
match, tooShort := SetPathValues(make([]PathValue, 1), "/{a}/{b}", []byte("/x/y"))
|
||||
if !tooShort {
|
||||
t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot")
|
||||
}
|
||||
if match {
|
||||
t.Error("want match=false when values could not be bound")
|
||||
}
|
||||
// A pattern that binds nothing needs no slots at all.
|
||||
match, tooShort = SetPathValues(nil, "/health", []byte("/health"))
|
||||
if !match || tooShort {
|
||||
t.Errorf("want match with no slots needed, got match=%v tooShort=%v", match, tooShort)
|
||||
}
|
||||
}
|
||||
|
||||
// Percent escapes are compared and bound raw. ServeMux unescapes segment by
|
||||
// segment, so "/users/x%2Fy" binds id="x/y" there and id="x%2Fy" here. Matching
|
||||
// agrees either way; only the bound bytes differ.
|
||||
func TestSetPathValuesEscaping(t *testing.T) {
|
||||
vals := make([]PathValue, 4)
|
||||
if match, _ := SetPathValues(vals, "/a%2Fb/{x}", []byte("/a%2Fb/v")); !match {
|
||||
t.Error("want literal escape in pattern to match the same bytes in path")
|
||||
}
|
||||
vals = make([]PathValue, 4)
|
||||
match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy"))
|
||||
if !match {
|
||||
t.Fatal("want match")
|
||||
}
|
||||
if got := string(vals[0].Value); got != "x%2Fy" {
|
||||
t.Errorf("want raw %q, got %q", "x%2Fy", got)
|
||||
}
|
||||
}
|
||||
|
||||
// renderPathValues joins the bound pairs for comparison, stopping at the first
|
||||
// unused slot.
|
||||
func renderPathValues(vals []PathValue) string {
|
||||
var sb strings.Builder
|
||||
for _, v := range vals {
|
||||
if v.Key == "" {
|
||||
break
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte('|')
|
||||
}
|
||||
sb.WriteString(v.Key)
|
||||
sb.WriteByte('=')
|
||||
sb.Write(v.Value)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Matching a request must not allocate: keys alias the mux's pattern and values
|
||||
// alias the request buffer, so nothing is copied per request.
|
||||
func TestSetPathValuesNoAlloc(t *testing.T) {
|
||||
vals := make([]PathValue, 8)
|
||||
path := []byte("/b/bk/o/a/b/c")
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
SetPathValues(vals, "/b/{bucket}/o/{obj...}", path)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("SetPathValues allocated %v times, want 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
// pathValueMux binds one wildcard pattern, standing in for a [Mux] that
|
||||
// supports them until MuxSlice sets setPathVal.
|
||||
type pathValueMux struct {
|
||||
pattern string
|
||||
handler HandlerFunc
|
||||
}
|
||||
|
||||
func (m *pathValueMux) MaxPathValues() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []PathValue) (string, HandlerFunc) {
|
||||
if ok, _ := SetPathValues(dst, m.pattern, path); ok {
|
||||
return m.pattern, m.handler
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// A wildcard bound by one request must not be readable by the next request the
|
||||
// same pooled exchange serves. A literal pattern binds nothing, so it never
|
||||
// overwrites the previous request's slots, and the values it would leak alias a
|
||||
// buffer the new request has already overwritten.
|
||||
func TestExchangePathValueClearedBetweenRequests(t *testing.T) {
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: 4,
|
||||
})
|
||||
|
||||
// First request binds id=42 off a wildcard pattern.
|
||||
var gotFirst string
|
||||
wildcard := &pathValueMux{pattern: "/users/{id}", handler: func(e *Exchange) {
|
||||
gotFirst = string(e.PathValue("id"))
|
||||
e.WriteHeader(200)
|
||||
}}
|
||||
conn := newConn("GET /users/42 HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("fresh exchange failed to acquire")
|
||||
}
|
||||
if err := Handle(exch, wildcard, nopBackoff); err != nil {
|
||||
t.Fatalf("first request: %s", err)
|
||||
}
|
||||
exch.Release()
|
||||
if gotFirst != "42" {
|
||||
t.Fatalf("want id=42 bound on the first request, got %q", gotFirst)
|
||||
}
|
||||
|
||||
// Second request matches a literal pattern, which binds nothing at all.
|
||||
var leaked []byte
|
||||
var sm MuxSlice
|
||||
sm.Handle("/health", func(e *Exchange) {
|
||||
leaked = e.PathValue("id")
|
||||
e.WriteHeader(200)
|
||||
})
|
||||
conn2 := newConn("GET /health HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn2.Hangup()
|
||||
if !exch.Acquire(conn2) {
|
||||
t.Fatal("released exchange failed to re-acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatalf("second request: %s", err)
|
||||
}
|
||||
if leaked != nil {
|
||||
t.Errorf("want no path value on a literal route, got id=%q from the previous request", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// A lookup tries each endpoint in turn and [SetPathValues] binds as it walks, so
|
||||
// a pattern that binds values and then fails must not leave them behind for the
|
||||
// pattern that does match: a handler would read a wildcard no matched pattern has.
|
||||
func TestMuxSliceNoStaleBindingsAcrossCandidates(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("/a/{x}/{y}/z", func(ex *Exchange) { t.Error("non-matching handler ran") })
|
||||
var gotP, gotX, gotY string
|
||||
sm.Handle("/a/{p}/b", func(ex *Exchange) {
|
||||
gotP = string(ex.PathValue("p"))
|
||||
gotX = string(ex.PathValue("x"))
|
||||
gotY = string(ex.PathValue("y"))
|
||||
ex.WriteHeader(200)
|
||||
})
|
||||
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET /a/1/b HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("fresh exchange failed to acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotP != "1" {
|
||||
t.Errorf("want p=1 from the matched pattern, got %q", gotP)
|
||||
}
|
||||
if gotX != "" || gotY != "" {
|
||||
t.Errorf("want no x/y from the failed candidate, got x=%q y=%q", gotX, gotY)
|
||||
}
|
||||
}
|
||||
|
||||
// A pattern ending in '/' carries no brace but is still a wildcard: the trailing
|
||||
// slash is an anonymous "{...}", see [SetPathValues]. MuxSlice must route it
|
||||
// through the same matcher rather than comparing the path literally.
|
||||
func TestMuxSliceTrailingSlashPattern(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "/files/", path: "/files/a/b", want: true},
|
||||
{pattern: "/files/", path: "/files/", want: true},
|
||||
{pattern: "/files/", path: "/files", want: false},
|
||||
{pattern: "/files/", path: "/other/a", want: false},
|
||||
{pattern: "/", path: "/anything/at/all", want: true},
|
||||
{pattern: "/", path: "/", want: true},
|
||||
// Without the trailing slash a pattern stays literal.
|
||||
{pattern: "/files", path: "/files/a", want: false},
|
||||
{pattern: "/files", path: "/files", want: true},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
var served bool
|
||||
sm.Handle(test.pattern, func(ex *Exchange) { served = true; ex.WriteHeader(200) })
|
||||
// MuxSlice must agree with the matcher it delegates to.
|
||||
if ok, _ := SetPathValues(nil, test.pattern, []byte(test.path)); ok != test.want {
|
||||
t.Fatalf("SetPathValues disagrees with the table: got %v", ok)
|
||||
}
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("fresh exchange failed to acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served != test.want {
|
||||
t.Errorf("want served=%v, got %v", test.want, served)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed registration is a programming error, and one that otherwise costs
|
||||
// a permanent silent 404 at runtime: "Get /x" parses to MethUnknown, which no
|
||||
// GET request ever matches but every extension-method request does. Fail at
|
||||
// registration, where the stack points at the offending line.
|
||||
func TestMuxSliceHandlePanicsOnBadRegistration(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reg string
|
||||
}{
|
||||
{name: "lowercase method", reg: "Get /x"},
|
||||
{name: "all lower method", reg: "get /x"},
|
||||
{name: "mixed case method", reg: "pOsT /x"},
|
||||
{name: "no leading slash", reg: "GET x"},
|
||||
{name: "bare path no slash", reg: "x"},
|
||||
{name: "empty path after method", reg: "GET "},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("want panic registering %q", test.reg)
|
||||
}
|
||||
}()
|
||||
sm.Handle(test.reg, func(ex *Exchange) {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An exact duplicate is unreachable code: the first registration always wins.
|
||||
func TestMuxSliceHandlePanicsOnDuplicate(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("want panic registering the same method and path twice")
|
||||
}
|
||||
}()
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
}
|
||||
|
||||
// Extension methods are legal and uppercase, so they must still register: only
|
||||
// the case-mangled forms are rejected.
|
||||
func TestMuxSliceHandleAllowsExtensionMethod(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("PROPFIND /dav", func(ex *Exchange) {})
|
||||
sm.Handle("/any-method", func(ex *Exchange) {}) // Bare path matches any method.
|
||||
if sm.MaxPathValues() != 0 {
|
||||
t.Errorf("want 0 path values, got %d", sm.MaxPathValues())
|
||||
}
|
||||
}
|
||||
|
||||
// A catch-all registered before the endpoints it sits above must not swallow
|
||||
// them. "/" is a wildcard pattern: its trailing slash is an anonymous "{...}",
|
||||
// so a purely first-match-wins scan hands every request to it and the specific
|
||||
// registrations below become dead code, answered with the root page instead of
|
||||
// their own body. Registering the site root first is the ordinary way to write
|
||||
// a mux, so the more specific pattern has to win regardless of order, as in
|
||||
// http.ServeMux.
|
||||
func TestMuxSliceSpecificPatternBeatsEarlierCatchAll(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
register []string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "root registered first",
|
||||
register: []string{"/", "/hello", "/cnt", "/6"},
|
||||
path: "/cnt",
|
||||
want: "/cnt",
|
||||
}, {
|
||||
name: "root registered last",
|
||||
register: []string{"/hello", "/cnt", "/6", "/"},
|
||||
path: "/cnt",
|
||||
want: "/cnt",
|
||||
}, {
|
||||
name: "root still serves the root path",
|
||||
register: []string{"/", "/cnt"},
|
||||
path: "/",
|
||||
want: "/",
|
||||
}, {
|
||||
name: "root still catches the unregistered",
|
||||
register: []string{"/", "/cnt"},
|
||||
path: "/nowhere",
|
||||
want: "/",
|
||||
}, {
|
||||
name: "subtree wildcard loses to its own literal",
|
||||
register: []string{"/files/", "/files/index"},
|
||||
path: "/files/index",
|
||||
want: "/files/index",
|
||||
}, {
|
||||
name: "subtree wildcard keeps the rest",
|
||||
register: []string{"/files/", "/files/index"},
|
||||
path: "/files/a/b",
|
||||
want: "/files/",
|
||||
}, {
|
||||
name: "longer literal prefix wins over shorter subtree",
|
||||
register: []string{"/", "/files/", "/files/a/b"},
|
||||
path: "/files/a/b",
|
||||
want: "/files/a/b",
|
||||
}, {
|
||||
name: "named wildcard loses to the literal it covers",
|
||||
register: []string{"/users/{id}", "/users/me"},
|
||||
path: "/users/me",
|
||||
want: "/users/me",
|
||||
}, {
|
||||
name: "named wildcard keeps everything else",
|
||||
register: []string{"/users/{id}", "/users/me"},
|
||||
path: "/users/42",
|
||||
want: "/users/{id}",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(len(test.register))
|
||||
for _, pattern := range test.register {
|
||||
sm.Handle(pattern, func(ex *Exchange) { ex.WriteHeader(200) })
|
||||
}
|
||||
pathVals := make([]PathValue, max(sm.MaxPathValues(), 0))
|
||||
got, handler := sm.LookupHandler(MethGet, []byte(test.path), pathVals)
|
||||
if handler == nil {
|
||||
t.Fatalf("%s matched no handler, want %q", test.path, test.want)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Errorf("%s matched pattern %q, want %q", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pathVals sizes the slice SetPathValues writes into, so it must count exactly
|
||||
// the wildcards that bind. Counting braces over-reports: "{$}" marks the path's
|
||||
// end, an anonymous "{...}" has no name, and a brace inside a literal segment is
|
||||
// not a wildcard at all. Each of those binds nothing.
|
||||
func TestMuxSliceMaxPathValuesCountsOnlyBindingWildcards(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
want int
|
||||
}{
|
||||
{pattern: "/health", want: 0},
|
||||
{pattern: "/", want: 0},
|
||||
{pattern: "/files/", want: 0}, // Anonymous trailing wildcard.
|
||||
{pattern: "/{$}", want: 0}, // End-of-path marker.
|
||||
{pattern: "/a/{$}", want: 0}, //
|
||||
{pattern: "/b_{bucket}", want: 0}, // Literal: brace not a whole segment.
|
||||
{pattern: "/{...}", want: 0}, // Multi wildcard with no name.
|
||||
{pattern: "/users/{id}", want: 1}, //
|
||||
{pattern: "/files/{p...}", want: 1}, //
|
||||
{pattern: "/{a}/{b}", want: 2}, //
|
||||
{pattern: "/b/{bucket}/o/{obj...}", want: 2},
|
||||
} {
|
||||
t.Run(test.pattern, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle(test.pattern, func(ex *Exchange) {})
|
||||
if got := sm.MaxPathValues(); got != test.want {
|
||||
t.Errorf("want %d path values, got %d", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sizing and routing are different questions: "{$}" binds no values yet still
|
||||
// needs the matcher, so an exact pathVals count must not send it to the literal
|
||||
// comparison instead.
|
||||
func TestMuxSliceZeroValueWildcardStillMatches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "/{$}", path: "/", want: true},
|
||||
{pattern: "/{$}", path: "/x", want: false},
|
||||
{pattern: "/a/{$}", path: "/a/", want: true},
|
||||
{pattern: "/a/{$}", path: "/a/b", want: false},
|
||||
{pattern: "/{...}", path: "/any/thing", want: true},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
var served bool
|
||||
sm.Handle(test.pattern, func(ex *Exchange) { served = true; ex.WriteHeader(200) })
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served != test.want {
|
||||
t.Errorf("want served=%v, got %v", test.want, served)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
//go:generate stringer -type Method -linecomment -output stringers.go
|
||||
|
||||
// reconfigureWait bounds how long [Router.Configure] waits for the previous
|
||||
// generation to stop serving before reusing its exchange buffers.
|
||||
const reconfigureWait = 10 * time.Millisecond
|
||||
|
||||
var (
|
||||
errNoRequestProto = errors.New("httphi: request line with no HTTP version")
|
||||
errBadRequestProto = errors.New("httphi: unsupported HTTP version in request line")
|
||||
errBusyExchanges = errors.New("httphi: exchanges still serving, cannot reuse their buffers")
|
||||
errRouterTornDown = errors.New("httphi: router torn down, configure it before serving")
|
||||
|
||||
errNotFormEncoded = errors.New("httphi: request body is not application/x-www-form-urlencoded")
|
||||
errNotMultipart = errors.New("httphi: request body is not multipart/form-data")
|
||||
errUnsupportedTransferCoding = errors.New("httphi: transfer coding not decoded, read the body directly")
|
||||
)
|
||||
|
||||
type conn = io.ReadWriteCloser
|
||||
|
||||
// Router serves HTTP connections handed to it with [Router.Handle], routing
|
||||
// each request to a handler found through its [Mux]. It plays the part of
|
||||
// http.Server minus the listening: accepting connections is the caller's job,
|
||||
// which is what lets the same router run over a TCP stack, a socket or a test
|
||||
// pipe.
|
||||
//
|
||||
// A Router owns the exchanges and goroutines that serve connections and sizes
|
||||
// both at [Router.Configure] time, so serving load costs no allocation and
|
||||
// bounded memory. Connections arriving with nothing left to serve them are
|
||||
// refused rather than queued, see [Router.Handle].
|
||||
//
|
||||
// Methods are safe for concurrent use. The zero value is not usable: configure
|
||||
// it first.
|
||||
type Router struct {
|
||||
mu sync.Mutex
|
||||
gen atomic.Uint32
|
||||
numGoro int
|
||||
reqBuf int
|
||||
respBuf int
|
||||
reqNumHeaderCap int
|
||||
maxPathValues int
|
||||
normalizeKeys bool
|
||||
pendingConns chan job
|
||||
mux Mux
|
||||
|
||||
globbuf []byte
|
||||
exchs []Exchange
|
||||
freeList *Exchange
|
||||
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// job is a connection waiting on an exchange for a worker goroutine to serve it.
|
||||
type job struct {
|
||||
exch *Exchange
|
||||
}
|
||||
|
||||
// RouterConfig configures a [Router]. See [Router.Configure].
|
||||
//
|
||||
// Each field below opens with Required, Conditional or Optional followed by the
|
||||
// constraint in brackets, that being what [RouterConfig.Validate] rejects on.
|
||||
type RouterConfig struct {
|
||||
// Required [-1 or >0] number of goroutines to spawn on [Router.Configure],
|
||||
// -1 meaning allocate them freely per connection instead.
|
||||
FixedNumGoroutines int
|
||||
// Required [>=32, sum with ResponseHeaderMinBufferSize <=65535] buffer for the
|
||||
// request header: request-target (URI), protocol and key/value pairs.
|
||||
RequestHeaderBufferSize int
|
||||
// Required [>=2, <=65535] buffer for response headers. Reuses unused request
|
||||
// memory so it is not a strict limit, and the status line does not count
|
||||
// towards it. Once consumed [Exchange.StageHeader] appends no more fields.
|
||||
ResponseHeaderMinBufferSize int
|
||||
// Required [>0] request header key/value pairs to parse before failing with
|
||||
// [StatusRequestHeaderFieldsTooLarge].
|
||||
RequestNumHeaderKVCap int
|
||||
|
||||
// Optional [any] normalization of response header field keys as they are
|
||||
// staged, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
|
||||
// Required [non-nil] resolver of each request's method and path to the handler
|
||||
// serving it. Routes must be registered before Configure, see [Mux.MaxPathValues].
|
||||
Mux Mux
|
||||
// Optional [nil disables] sink for failed exchanges.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
// minRequestHeaderBuffer is the smallest request buffer [httpraw.Header]
|
||||
// accepts with buffer growth disabled, which is how exchanges are configured.
|
||||
minRequestHeaderBuffer = 32
|
||||
// minResponseHeaderBuffer is the room [Exchange.FlushHeader] needs for the
|
||||
// CRLF closing the header block, written even when no field was staged.
|
||||
minResponseHeaderBuffer = len("\r\n")
|
||||
// maxExchangeBuffer bounds an exchange's whole buffer: [Exchange] indexes it
|
||||
// with uint16 offsets, so a larger one would be addressed truncated.
|
||||
maxExchangeBuffer = math.MaxUint16
|
||||
)
|
||||
|
||||
// Validate returns a non-nil error if the configuration cannot be used to
|
||||
// configure a [Router].
|
||||
func (cfg RouterConfig) Validate() error {
|
||||
workerMode := cfg.workerMode()
|
||||
switch {
|
||||
case cfg.Mux == nil,
|
||||
!workerMode && cfg.FixedNumGoroutines != -1,
|
||||
cfg.RequestNumHeaderKVCap <= 0,
|
||||
cfg.RequestHeaderBufferSize < minRequestHeaderBuffer,
|
||||
cfg.ResponseHeaderMinBufferSize < minResponseHeaderBuffer,
|
||||
cfg.ResponseHeaderMinBufferSize > maxExchangeBuffer,
|
||||
cfg.RequestHeaderBufferSize > maxExchangeBuffer-cfg.ResponseHeaderMinBufferSize:
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
if workerMode {
|
||||
// Buffer sizes are bounded by maxExchangeBuffer above so the sum cannot
|
||||
// overflow; the products below allocate and can.
|
||||
exchBuf := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
|
||||
if cfg.FixedNumGoroutines > math.MaxInt/exchBuf ||
|
||||
cfg.FixedNumGoroutines > math.MaxInt/cfg.RequestNumHeaderKVCap {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg RouterConfig) workerMode() bool {
|
||||
return cfg.FixedNumGoroutines > 0
|
||||
}
|
||||
|
||||
// Shutdown stops the router once in-flight exchanges finish; until
|
||||
// [Router.Configure] is called again, connections are refused with a
|
||||
// non-nil error. Configure calls it before installing a new generation.
|
||||
func (r *Router) Shutdown() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.shutdownLocked()
|
||||
}
|
||||
|
||||
// shutdownLocked is [Router.Shutdown] but without locking requirement.
|
||||
func (r *Router) shutdownLocked() {
|
||||
r.gen.Add(1)
|
||||
if r.pendingConns != nil {
|
||||
close(r.pendingConns)
|
||||
r.pendingConns = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Configure prepares the router to serve connections, tearing down the previous
|
||||
// generation of goroutines and exchanges first. In worker mode it spawns
|
||||
// [RouterConfig.FixedNumGoroutines] goroutines and allocates their exchange
|
||||
// buffers up front, so the router's memory use does not grow with load.
|
||||
//
|
||||
// Configure may be called on a serving router, but since the exchange buffers
|
||||
// are reused it waits for connections in flight to finish and fails with a
|
||||
// non-nil error rather than reconfigure buffers still being served from.
|
||||
func (r *Router) Configure(cfg RouterConfig) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.shutdownLocked()
|
||||
gen := r.gen.Load()
|
||||
numgoro := cfg.FixedNumGoroutines
|
||||
workerMode := cfg.workerMode()
|
||||
|
||||
r.reqNumHeaderCap = cfg.RequestNumHeaderKVCap
|
||||
r.reqBuf = cfg.RequestHeaderBufferSize
|
||||
r.respBuf = cfg.ResponseHeaderMinBufferSize
|
||||
r.mux = cfg.Mux
|
||||
r.log = cfg.Logger
|
||||
maxPathValues := cfg.Mux.MaxPathValues()
|
||||
if maxPathValues < 0 {
|
||||
return errors.New("Mux paths must be registered before configuring Router")
|
||||
}
|
||||
r.maxPathValues = maxPathValues
|
||||
r.normalizeKeys = cfg.NormalizeOutgoingKeys
|
||||
// Freelist entries were sized by the outgoing configuration: recycling one
|
||||
// would serve a request with buffer limits cfg never asked for.
|
||||
r.freeList = nil
|
||||
if !workerMode {
|
||||
r.numGoro = 0
|
||||
r.pendingConns = nil
|
||||
return nil
|
||||
}
|
||||
if workerMode {
|
||||
jobqueue := make(chan job, cfg.FixedNumGoroutines)
|
||||
if gen > 1 {
|
||||
// Exchange buffers below are reused: the previous generation must be
|
||||
// done serving before they may be handed to the new one.
|
||||
err := r.awaitIdleExchangesLocked(reconfigureWait)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
internal.SliceReuse(&r.exchs, numgoro)
|
||||
r.exchs = r.exchs[:numgoro]
|
||||
rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
|
||||
internal.SliceReuse(&r.globbuf, numgoro*rawBuflen)
|
||||
|
||||
for i := range numgoro {
|
||||
// TODO exchange buffer alloc
|
||||
goff := i * rawBuflen
|
||||
// r.globbuf[goff:goff+rawBuflen], cfg.RequestHeaderBufferSize, cfg.RequestNumHeaderCap, cfg.NormalizeOutgoingKeys
|
||||
r.exchs[i].Configure(ExchangeConfig{
|
||||
RawBuf: r.globbuf[goff : goff+rawBuflen],
|
||||
RequestBufferLim: cfg.RequestHeaderBufferSize,
|
||||
NumHeaderKVCap: cfg.RequestNumHeaderKVCap,
|
||||
NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys,
|
||||
NoRequestBufferGrowth: true, // Hard memory limit.
|
||||
MaxPathValues: maxPathValues,
|
||||
})
|
||||
go r.goroWorker(gen, jobqueue, cfg.Mux)
|
||||
}
|
||||
r.pendingConns = jobqueue
|
||||
r.numGoro = numgoro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitIdleExchangesLocked waits up to maxWait for exchanges of the previous
|
||||
// generation to finish serving so their buffers may be reused. Requires r.mu
|
||||
// held; the lock is released while waiting since [Router.freeExch] needs it to
|
||||
// free the exchanges being waited on.
|
||||
func (r *Router) awaitIdleExchangesLocked(maxWait time.Duration) error {
|
||||
const pollInterval = time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += pollInterval {
|
||||
busy := false
|
||||
for i := range r.exchs {
|
||||
if r.exchs[i].acquired.Load() {
|
||||
busy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !busy {
|
||||
return nil
|
||||
} else if waited >= maxWait {
|
||||
return errBusyExchanges
|
||||
}
|
||||
r.mu.Unlock()
|
||||
time.Sleep(pollInterval)
|
||||
r.mu.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle takes ownership of conn and serves one exchange on it, closing it when
|
||||
// done. It does not block on the exchange: the connection is handed to a
|
||||
// goroutine and Handle returns immediately.
|
||||
//
|
||||
// Handle returns [lneto.ErrExhausted] when no exchange is free,
|
||||
// [lneto.ErrPacketDrop] when the queue of connections awaiting a goroutine is
|
||||
// full, and an error when the router's goroutines have been torn down. On every
|
||||
// one of them conn is left untouched and unclosed for the caller to dispose of:
|
||||
// refusing connections is how a router with fixed memory applies backpressure.
|
||||
func (r *Router) Handle(conn io.ReadWriteCloser) error {
|
||||
// Exchange acquisition and the configuration it is served with must be read
|
||||
// under the same lock: [Router.Configure] may run concurrently.
|
||||
r.mu.Lock()
|
||||
numGoro, mux := r.numGoro, r.mux
|
||||
gen := r.gen.Load() // Generation whose buffers the exchange below is sized by.
|
||||
if numGoro > 0 && r.pendingConns == nil {
|
||||
// Goroutines torn down: refuse before claiming an exchange.
|
||||
r.mu.Unlock()
|
||||
return errRouterTornDown
|
||||
}
|
||||
exch := r.getExchLocked(conn)
|
||||
if exch == nil {
|
||||
r.mu.Unlock()
|
||||
return lneto.ErrExhausted
|
||||
} else if numGoro == 0 {
|
||||
r.mu.Unlock()
|
||||
go r.goroHandle(gen, exch, mux)
|
||||
return nil
|
||||
}
|
||||
// Enqueue under the lock: [Router.Configure] closes pendingConns while
|
||||
// holding it, so an unlocked send could land on a closed channel. The send
|
||||
// never blocks, so holding the lock cannot stall a worker.
|
||||
var enqueued bool
|
||||
select {
|
||||
case r.pendingConns <- job{exch: exch}:
|
||||
enqueued = true
|
||||
default:
|
||||
// pendingConns cannot store another Conn, we drop and return error.
|
||||
exch.acquired.Store(false) // release.
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if enqueued {
|
||||
return nil
|
||||
}
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
|
||||
func (r *Router) goroWorker(gen uint32, queue chan job, mux Mux) {
|
||||
for job := range queue {
|
||||
exch := job.exch
|
||||
if exch == nil {
|
||||
panic("httphi: unreachable nil job")
|
||||
} else if gen != r.gen.Load() {
|
||||
// Not released with freeExch since generation torn down,
|
||||
// new buffer may have been allocated for Exchanges.
|
||||
exch.Release()
|
||||
continue
|
||||
}
|
||||
r.goroHandle(gen, exch, mux)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) goroHandle(gen uint32, exch *Exchange, mux Mux) {
|
||||
defer r.freeExch(gen, exch)
|
||||
err := Handle(exch, mux, nil)
|
||||
if err != nil {
|
||||
if exch.readErr != nil {
|
||||
r.error("goroHandle:ReadFromLimited", slog.String("err", err.Error()))
|
||||
} else {
|
||||
r.error("goroHandle:TryParse?", slog.String("err", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// freeExch releases exch and offers it to the freelist for reuse. gen is the
|
||||
// generation exch was acquired under: an exchange outliving its generation is
|
||||
// dropped, its buffers being sized by a configuration the router no longer
|
||||
// serves and possibly carved out of a globbuf it no longer owns.
|
||||
func (r *Router) freeExch(gen uint32, exch *Exchange) {
|
||||
const freelistMaxDepth = 5
|
||||
r.mu.Lock()
|
||||
if gen != r.gen.Load() {
|
||||
exch.Release()
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
depth := 0
|
||||
for node := r.freeList; node != nil && depth < freelistMaxDepth; node = node.nextFree {
|
||||
depth++
|
||||
}
|
||||
if depth < freelistMaxDepth {
|
||||
// Push at head: appending at the tail would drop every node past the
|
||||
// depth limit instead of dropping the exchange we cannot store.
|
||||
exch.nextFree = r.freeList
|
||||
r.freeList = exch
|
||||
} else {
|
||||
exch.nextFree = nil // Freelist full, exchange is dropped.
|
||||
}
|
||||
exch.Release()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// getExchLocked returns an exchange acquired on conn. Requires r.mu held.
|
||||
func (r *Router) getExchLocked(conn conn) (exch *Exchange) {
|
||||
if r.freeList != nil {
|
||||
// Successor must be read before Acquire: Acquire clears nextFree, so
|
||||
// popping afterwards would truncate the freelist to the popped node.
|
||||
next := r.freeList.nextFree
|
||||
if r.freeList.Acquire(conn) {
|
||||
exch = r.freeList
|
||||
r.freeList = next
|
||||
return exch
|
||||
}
|
||||
}
|
||||
// Unbounded mode is stored as zero, see [Router.Configure].
|
||||
workerMode := r.numGoro > 0
|
||||
if workerMode {
|
||||
for i := range r.exchs {
|
||||
if r.exchs[i].Acquire(conn) {
|
||||
return &r.exchs[i]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unbounded growth mode when r.numGoro==0.
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, r.respBuf+r.reqBuf),
|
||||
RequestBufferLim: r.reqBuf,
|
||||
NumHeaderKVCap: r.reqNumHeaderCap,
|
||||
NormalizeOutgoingKeys: r.normalizeKeys,
|
||||
NoRequestBufferGrowth: true,
|
||||
MaxPathValues: r.maxPathValues,
|
||||
})
|
||||
exch.Acquire(conn) // Fresh exchange, CAS cannot fail.
|
||||
return exch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) error(msg string, attrs ...slog.Attr) {
|
||||
internal.LogAttrs(r.log, slog.LevelError, msg, attrs...)
|
||||
}
|
||||
|
||||
func (r *Router) info(msg string, attrs ...slog.Attr) {
|
||||
internal.LogAttrs(r.log, slog.LevelInfo, msg, attrs...)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rwconn is a in-memory conn. The router handles connections on another
|
||||
// goroutine so every field is guarded; onClose lets tests await the handler.
|
||||
//
|
||||
// A drained rwconn reads (0,nil) as a live socket with no data pending would,
|
||||
// so a partial request can be completed with AddReadable mid-handling. Tests
|
||||
// that need the peer to hang up call Hangup.
|
||||
type rwconn struct {
|
||||
mu sync.Mutex
|
||||
readable bytes.Buffer
|
||||
segments []string
|
||||
written bytes.Buffer
|
||||
closed bool
|
||||
hangup bool
|
||||
failWr int
|
||||
onClose chan struct{}
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
// newConn returns a conn preloaded with request and whose Close is observable
|
||||
// with [rwconn.AwaitClose].
|
||||
func newConn(request string) *rwconn {
|
||||
r := &rwconn{onClose: make(chan struct{})}
|
||||
r.AddReadable([]byte(request))
|
||||
return r
|
||||
}
|
||||
|
||||
// AddSegment queues data delivered on a later read, once everything already
|
||||
// pending has been read. Models a request split over several TCP segments
|
||||
// without depending on goroutine scheduling.
|
||||
func (r *rwconn) AddSegment(b string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.segments = append(r.segments, b)
|
||||
}
|
||||
|
||||
// FailWrites makes the next n writes fail, as a conn refusing further data
|
||||
// would. Later writes succeed.
|
||||
func (r *rwconn) FailWrites(n int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.failWr = n
|
||||
}
|
||||
|
||||
// Hangup makes reads past the pending data return [io.EOF], as a peer that
|
||||
// closed its side of the connection would.
|
||||
func (r *rwconn) Hangup() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.hangup = true
|
||||
}
|
||||
|
||||
func (r *rwconn) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.closed {
|
||||
r.closed = true
|
||||
if r.onClose != nil {
|
||||
close(r.onClose)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AwaitClose blocks until the connection is closed by its handler or timeout elapses.
|
||||
func (r *rwconn) AwaitClose(t *testing.T, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-r.onClose:
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("timed out awaiting connection close by handler")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rwconn) Read(b []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return 0, net.ErrClosed
|
||||
} else if r.deadlineExceeded() {
|
||||
return 0, context.DeadlineExceeded
|
||||
} else if r.readable.Len() == 0 {
|
||||
if len(r.segments) > 0 {
|
||||
r.readable.WriteString(r.segments[0])
|
||||
r.segments = r.segments[1:]
|
||||
return r.readable.Read(b)
|
||||
}
|
||||
if r.hangup {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return 0, nil // No data pending, handler backs off and retries.
|
||||
}
|
||||
return r.readable.Read(b)
|
||||
}
|
||||
func (r *rwconn) Write(b []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return 0, net.ErrClosed
|
||||
} else if r.deadlineExceeded() {
|
||||
return 0, context.DeadlineExceeded
|
||||
} else if r.failWr > 0 {
|
||||
r.failWr--
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
return r.written.Write(b)
|
||||
}
|
||||
func (r *rwconn) deadlineExceeded() bool {
|
||||
return !r.deadline.IsZero() && time.Since(r.deadline) > 0
|
||||
}
|
||||
func (r *rwconn) AddReadable(b []byte) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.readable.Write(b)
|
||||
}
|
||||
|
||||
// SetDeadline makes reads and writes past t fail, as a conn with a read
|
||||
// deadline set would.
|
||||
func (r *rwconn) SetDeadline(t time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.deadline = t
|
||||
}
|
||||
|
||||
// IsClosed reports whether the connection was closed by its handler.
|
||||
func (r *rwconn) IsClosed() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.closed
|
||||
}
|
||||
|
||||
func (r *rwconn) ViewWritten() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.written.String()
|
||||
}
|
||||
|
||||
var _ Mux = (*MuxSlice)(nil)
|
||||
|
||||
func configSynchronousRouter(t *testing.T, router *Router, bufferSize int, mux Mux) {
|
||||
err := router.Configure(RouterConfig{
|
||||
FixedNumGoroutines: -1,
|
||||
Mux: mux,
|
||||
RequestHeaderBufferSize: bufferSize,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
ResponseHeaderMinBufferSize: bufferSize,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterGet(t *testing.T) {
|
||||
const bufferSize = 1024
|
||||
const expectResponse = "its time"
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, expectResponse))
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
|
||||
conn := newConn("GET / HTTP/1.1\r\nHost: tinygo.org\r\n\r\n")
|
||||
err := router.Handle(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.AwaitClose(t, time.Second)
|
||||
|
||||
got := conn.ViewWritten()
|
||||
if !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
|
||||
t.Errorf("want 200 status line, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, expectResponse) {
|
||||
t.Errorf("want body %q at end of response, got %q", expectResponse, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The handler observes the request line and header fields the router parsed.
|
||||
func TestRouterRequestVisibleToHandler(t *testing.T) {
|
||||
const bufferSize = 1024
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
var gotMethod, gotURI, gotHost string
|
||||
var gotMethodEnum Method
|
||||
sm.Handle("GET /index.html", func(ex *Exchange) {
|
||||
gotMethod = string(ex.RequestMethodRaw())
|
||||
gotMethodEnum = ex.RequestMethod()
|
||||
gotURI = string(ex.RequestTarget())
|
||||
gotHost = string(ex.RequestHeader("Host"))
|
||||
ex.WriteHeader(200)
|
||||
})
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
|
||||
conn := newConn("GET /index.html HTTP/1.1\r\nHost: tinygo.org\r\n\r\n")
|
||||
if err := router.Handle(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.AwaitClose(t, time.Second)
|
||||
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("want method %q, got %q", "GET", gotMethod)
|
||||
}
|
||||
if gotMethodEnum != MethGet {
|
||||
t.Errorf("want method enum %q, got %q", MethGet, gotMethodEnum)
|
||||
}
|
||||
if gotURI != "/index.html" {
|
||||
t.Errorf("want URI %q, got %q", "/index.html", gotURI)
|
||||
}
|
||||
if gotHost != "tinygo.org" {
|
||||
t.Errorf("want Host %q, got %q", "tinygo.org", gotHost)
|
||||
}
|
||||
}
|
||||
|
||||
// Router must route on method and URI, and must not invoke a handler for
|
||||
// requests it has no registration for.
|
||||
func TestRouterMux(t *testing.T) {
|
||||
const bufferSize = 1024
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
request string
|
||||
want string // Response body the matched handler must have written.
|
||||
wantNoHandler bool // No registration matches: the router must answer 404 itself.
|
||||
}{
|
||||
{name: "get root", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n", want: "root"},
|
||||
{name: "get page", request: "GET /page HTTP/1.1\r\nHost: h\r\n\r\n", want: "page"},
|
||||
{name: "any method", request: "DELETE /any HTTP/1.1\r\nHost: h\r\n\r\n", want: "any"},
|
||||
{name: "method mismatch", request: "POST / HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
|
||||
{name: "unknown uri", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
// "/{$}" is the root alone: a bare "/" is a catch-all and would serve
|
||||
// "root" for /page and /nowhere as well, see [SetPathValues].
|
||||
sm.Handle("GET /{$}", staticPage(t, "root"))
|
||||
sm.Handle("GET /page", staticPage(t, "page"))
|
||||
sm.Handle("/any", staticPage(t, "any")) // No method: matches any.
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
|
||||
conn := newConn(test.request)
|
||||
if err := router.Handle(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.AwaitClose(t, time.Second)
|
||||
|
||||
got := conn.ViewWritten()
|
||||
_, body, found := strings.Cut(got, "\r\n\r\n")
|
||||
if !found {
|
||||
t.Fatalf("header block never terminated: %q", got)
|
||||
}
|
||||
if test.wantNoHandler {
|
||||
if !strings.HasPrefix(got, "HTTP/1.1 404 ") {
|
||||
t.Errorf("want a 404 answer, got %q", got)
|
||||
}
|
||||
if body != "" {
|
||||
t.Errorf("no handler must run, got body %q", body)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
|
||||
t.Errorf("want a 200 answer, got %q", got)
|
||||
}
|
||||
if body != test.want {
|
||||
t.Errorf("want body %q, got %q", test.want, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A request arriving in pieces (TCP segmentation) must still be handled.
|
||||
func TestRouterSplitRequest(t *testing.T) {
|
||||
const bufferSize = 1024
|
||||
const expectResponse = "split ok"
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, expectResponse))
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
|
||||
conn := newConn("GET / HTTP/1.1\r\nHo")
|
||||
conn.AddSegment("st: tinygo.org\r\n\r")
|
||||
conn.AddSegment("\n") // Final CRLF lands in its own segment.
|
||||
if err := router.Handle(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.AwaitClose(t, time.Second)
|
||||
|
||||
if got := conn.ViewWritten(); !strings.HasSuffix(got, expectResponse) {
|
||||
t.Errorf("want body %q, got response %q", expectResponse, got)
|
||||
}
|
||||
}
|
||||
|
||||
func staticPage(t *testing.T, page string) HandlerFunc {
|
||||
return func(ex *Exchange) {
|
||||
var rw ExchangeRW // Streaming APIs take the ReadWriter view.
|
||||
ex.ReadWriter(&rw)
|
||||
n, err := io.WriteString(&rw, page)
|
||||
// Handler runs on the router goroutine: Error, never Fatal.
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else if n != len(page) {
|
||||
t.Error("expected written ", len(page), "got", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An exchange freed by the outgoing generation carries that generation's
|
||||
// buffers. Recycling it under a new configuration serves the request with
|
||||
// buffer limits the new [RouterConfig] never asked for.
|
||||
func TestRouterReconfigureDropsStaleExchanges(t *testing.T) {
|
||||
const smallBuf, largeBuf = 256, 1024
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
bufsize := make(chan int, 2)
|
||||
sm.Handle("GET /", func(ex *Exchange) { bufsize <- len(ex.UnsafeRawBuffer()) })
|
||||
serve := func(want int) {
|
||||
t.Helper()
|
||||
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if err := router.Handle(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-bufsize; got != want {
|
||||
t.Errorf("want exchange buffer %d, got %d", want, got)
|
||||
}
|
||||
conn.AwaitClose(t, time.Second) // Exchange hits the freelist on close.
|
||||
}
|
||||
|
||||
configSynchronousRouter(t, &router, smallBuf, &sm)
|
||||
serve(2 * smallBuf)
|
||||
configSynchronousRouter(t, &router, largeBuf, &sm)
|
||||
serve(2 * largeBuf)
|
||||
}
|
||||
|
||||
// Configure writes the fields Handle reads; concurrent use must not race.
|
||||
func TestRouterConfigureHandleRace(t *testing.T) {
|
||||
const bufferSize = 1024
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
if err := router.Handle(conn); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Reconfiguring a running router tears down the job queue that Handle may be
|
||||
// sending a connection on. Connections may be dropped, but never panic.
|
||||
// A torn down router has nothing left to serve with: it must say so instead of
|
||||
// dropping the connection as if it were merely busy.
|
||||
func TestRouterHandleAfterTeardown(t *testing.T) {
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
err := router.Configure(RouterConfig{
|
||||
FixedNumGoroutines: 2,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
ResponseHeaderMinBufferSize: 512,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router.Shutdown()
|
||||
|
||||
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
if err = router.Handle(conn); err != errRouterTornDown {
|
||||
t.Errorf("want errRouterTornDown, got %v", err)
|
||||
}
|
||||
if conn.IsClosed() {
|
||||
t.Error("refused connection must be left for the caller to dispose of")
|
||||
}
|
||||
}
|
||||
|
||||
// Tearing down a generation abandons the connections queued for it. They were
|
||||
// taken ownership of by Handle, so they must be closed and their exchanges
|
||||
// released: an exchange left claimed by a torn down generation is a buffer the
|
||||
// router can never reconfigure again.
|
||||
func TestRouterTeardownReleasesQueuedConns(t *testing.T) {
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
const numGoro = 2
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
cfg := RouterConfig{
|
||||
FixedNumGoroutines: numGoro,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
ResponseHeaderMinBufferSize: 512,
|
||||
}
|
||||
// Handing connections over and tearing down immediately leaves them queued
|
||||
// for workers that will never serve them. Rounds bound the scheduling luck
|
||||
// needed; a single leaked exchange also fails every later Configure.
|
||||
var conns [numGoro]*rwconn
|
||||
for range 30 {
|
||||
// errBusyExchanges is legitimate backpressure while the previous
|
||||
// generation drops its connections, but it must not outlive it.
|
||||
var err error
|
||||
for range 100 {
|
||||
if err = router.Configure(cfg); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal("reconfigure after teardown:", err)
|
||||
}
|
||||
for i := range conns {
|
||||
conns[i] = newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conns[i].Hangup()
|
||||
if err := router.Handle(conns[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router.Shutdown()
|
||||
for i := range conns {
|
||||
// Handle took ownership of the connection: served or dropped, the
|
||||
// router closes it.
|
||||
conns[i].AwaitClose(t, time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterConfigureDuringWorkerHandle(t *testing.T) {
|
||||
var (
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
cfg := RouterConfig{
|
||||
FixedNumGoroutines: 2,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
ResponseHeaderMinBufferSize: 512,
|
||||
}
|
||||
if err := router.Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer router.Shutdown()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 300 {
|
||||
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
router.Handle(conn) // Drops are fine, panics are not.
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Each Configure sleeps 5ms tearing down the previous generation, keep
|
||||
// the count low and let the Handle loop supply the concurrency.
|
||||
for range 20 {
|
||||
// errBusyExchanges is legitimate backpressure: the previous
|
||||
// generation was still serving when the buffers were needed.
|
||||
if err := router.Configure(cfg); err != nil && err != errBusyExchanges {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package httphi
|
||||
|
||||
// StatusText returns a text for the HTTP status code. It returns the empty
|
||||
// string if the code is unknown.
|
||||
func StatusText(code int) string {
|
||||
switch status(code) {
|
||||
case StatusContinue:
|
||||
return "Continue"
|
||||
case StatusSwitchingProtocols:
|
||||
return "Switching Protocols"
|
||||
case StatusProcessing:
|
||||
return "Processing"
|
||||
case StatusEarlyHints:
|
||||
return "Early Hints"
|
||||
case StatusOK:
|
||||
return "OK"
|
||||
case StatusCreated:
|
||||
return "Created"
|
||||
case StatusAccepted:
|
||||
return "Accepted"
|
||||
case StatusNonAuthoritativeInfo:
|
||||
return "Non-Authoritative Information"
|
||||
case StatusNoContent:
|
||||
return "No Content"
|
||||
case StatusResetContent:
|
||||
return "Reset Content"
|
||||
case StatusPartialContent:
|
||||
return "Partial Content"
|
||||
case StatusMultiStatus:
|
||||
return "Multi-Status"
|
||||
case StatusAlreadyReported:
|
||||
return "Already Reported"
|
||||
case StatusIMUsed:
|
||||
return "IM Used"
|
||||
case StatusMultipleChoices:
|
||||
return "Multiple Choices"
|
||||
case StatusMovedPermanently:
|
||||
return "Moved Permanently"
|
||||
case StatusFound:
|
||||
return "Found"
|
||||
case StatusSeeOther:
|
||||
return "See Other"
|
||||
case StatusNotModified:
|
||||
return "Not Modified"
|
||||
case StatusUseProxy:
|
||||
return "Use Proxy"
|
||||
case StatusTemporaryRedirect:
|
||||
return "Temporary Redirect"
|
||||
case StatusPermanentRedirect:
|
||||
return "Permanent Redirect"
|
||||
case StatusBadRequest:
|
||||
return "Bad Request"
|
||||
case StatusUnauthorized:
|
||||
return "Unauthorized"
|
||||
case StatusPaymentRequired:
|
||||
return "Payment Required"
|
||||
case StatusForbidden:
|
||||
return "Forbidden"
|
||||
case StatusNotFound:
|
||||
return "Not Found"
|
||||
case StatusMethodNotAllowed:
|
||||
return "Method Not Allowed"
|
||||
case StatusNotAcceptable:
|
||||
return "Not Acceptable"
|
||||
case StatusProxyAuthRequired:
|
||||
return "Proxy Authentication Required"
|
||||
case StatusRequestTimeout:
|
||||
return "Request Timeout"
|
||||
case StatusConflict:
|
||||
return "Conflict"
|
||||
case StatusGone:
|
||||
return "Gone"
|
||||
case StatusLengthRequired:
|
||||
return "Length Required"
|
||||
case StatusPreconditionFailed:
|
||||
return "Precondition Failed"
|
||||
case StatusRequestEntityTooLarge:
|
||||
return "Request Entity Too Large"
|
||||
case StatusRequestURITooLong:
|
||||
return "Request URI Too Long"
|
||||
case StatusUnsupportedMediaType:
|
||||
return "Unsupported Media Type"
|
||||
case StatusRequestedRangeNotSatisfiable:
|
||||
return "Requested Range Not Satisfiable"
|
||||
case StatusExpectationFailed:
|
||||
return "Expectation Failed"
|
||||
case StatusTeapot:
|
||||
return "I'm a teapot"
|
||||
case StatusMisdirectedRequest:
|
||||
return "Misdirected Request"
|
||||
case StatusUnprocessableEntity:
|
||||
return "Unprocessable Entity"
|
||||
case StatusLocked:
|
||||
return "Locked"
|
||||
case StatusFailedDependency:
|
||||
return "Failed Dependency"
|
||||
case StatusTooEarly:
|
||||
return "Too Early"
|
||||
case StatusUpgradeRequired:
|
||||
return "Upgrade Required"
|
||||
case StatusPreconditionRequired:
|
||||
return "Precondition Required"
|
||||
case StatusTooManyRequests:
|
||||
return "Too Many Requests"
|
||||
case StatusRequestHeaderFieldsTooLarge:
|
||||
return "Request Header Fields Too Large"
|
||||
case StatusUnavailableForLegalReasons:
|
||||
return "Unavailable For Legal Reasons"
|
||||
case StatusInternalServerError:
|
||||
return "Internal Server Error"
|
||||
case StatusNotImplemented:
|
||||
return "Not Implemented"
|
||||
case StatusBadGateway:
|
||||
return "Bad Gateway"
|
||||
case StatusServiceUnavailable:
|
||||
return "Service Unavailable"
|
||||
case StatusGatewayTimeout:
|
||||
return "Gateway Timeout"
|
||||
case StatusHTTPVersionNotSupported:
|
||||
return "HTTP Version Not Supported"
|
||||
case StatusVariantAlsoNegotiates:
|
||||
return "Variant Also Negotiates"
|
||||
case StatusInsufficientStorage:
|
||||
return "Insufficient Storage"
|
||||
case StatusLoopDetected:
|
||||
return "Loop Detected"
|
||||
case StatusNotExtended:
|
||||
return "Not Extended"
|
||||
case StatusNetworkAuthenticationRequired:
|
||||
return "Network Authentication Required"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const ()
|
||||
|
||||
type status int
|
||||
|
||||
// HTTP status codes as registered with IANA.
|
||||
// See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
||||
const (
|
||||
// RFC 9110, 15.2.1
|
||||
StatusContinue = 100 // Continue
|
||||
// RFC 9110, 15.2.2
|
||||
StatusSwitchingProtocols = 101 // Switching Protocols
|
||||
// RFC 2518, 10.1
|
||||
StatusProcessing = 102 // Processing
|
||||
// RFC 8297
|
||||
StatusEarlyHints = 103 // Early Hints
|
||||
|
||||
// RFC 9110, 15.3.1
|
||||
StatusOK = 200 // OK
|
||||
// RFC 9110, 15.3.2
|
||||
StatusCreated = 201 // Created
|
||||
// RFC 9110, 15.3.3
|
||||
StatusAccepted = 202 // Accepted
|
||||
// RFC 9110, 15.3.4
|
||||
StatusNonAuthoritativeInfo = 203 // Non-Authoritative Information
|
||||
// RFC 9110, 15.3.5
|
||||
StatusNoContent = 204 // No Content
|
||||
// RFC 9110, 15.3.6
|
||||
StatusResetContent = 205 // Reset Content
|
||||
// RFC 9110, 15.3.7
|
||||
StatusPartialContent = 206 // Partial Content
|
||||
// RFC 4918, 11.1
|
||||
StatusMultiStatus = 207 // Multi-Status
|
||||
// RFC 5842, 7.1
|
||||
StatusAlreadyReported = 208 // Already Reported
|
||||
// RFC 3229, 10.4.1
|
||||
StatusIMUsed = 226 // IM Used
|
||||
|
||||
// RFC 9110, 15.4.1
|
||||
StatusMultipleChoices = 300 // Multiple Choices
|
||||
// RFC 9110, 15.4.2
|
||||
StatusMovedPermanently = 301 // Moved Permanently
|
||||
// RFC 9110, 15.4.3
|
||||
StatusFound = 302 // Found
|
||||
// RFC 9110, 15.4.4
|
||||
StatusSeeOther = 303 // See Other
|
||||
// RFC 9110, 15.4.5
|
||||
StatusNotModified = 304 // Not Modified
|
||||
// RFC 9110, 15.4.6
|
||||
StatusUseProxy = 305 // Use Proxy
|
||||
// RFC 9110, 15.4.7 (Unused)
|
||||
_ = 306
|
||||
// RFC 9110, 15.4.8
|
||||
StatusTemporaryRedirect = 307 // Temporary Redirect
|
||||
// RFC 9110, 15.4.9
|
||||
StatusPermanentRedirect = 308 // Permanent Redirect
|
||||
|
||||
// RFC 9110, 15.5.1
|
||||
StatusBadRequest = 400 // Bad Request
|
||||
// RFC 9110, 15.5.2
|
||||
StatusUnauthorized = 401 // Unauthorized
|
||||
// RFC 9110, 15.5.3
|
||||
StatusPaymentRequired = 402 // Payment Required
|
||||
// RFC 9110, 15.5.4
|
||||
StatusForbidden = 403 // Forbidden
|
||||
// RFC 9110, 15.5.5
|
||||
StatusNotFound = 404 // Not Found
|
||||
// RFC 9110, 15.5.6
|
||||
StatusMethodNotAllowed = 405 // Method Not Allowed
|
||||
// RFC 9110, 15.5.7
|
||||
StatusNotAcceptable = 406 // Not Acceptable
|
||||
// RFC 9110, 15.5.8
|
||||
StatusProxyAuthRequired = 407 // Proxy Authentication Required
|
||||
// RFC 9110, 15.5.9
|
||||
StatusRequestTimeout = 408 // Request Timeout
|
||||
// RFC 9110, 15.5.10
|
||||
StatusConflict = 409 // Conflict
|
||||
// RFC 9110, 15.5.11
|
||||
StatusGone = 410 // Gone
|
||||
// RFC 9110, 15.5.12
|
||||
StatusLengthRequired = 411 // Length Required
|
||||
// RFC 9110, 15.5.13
|
||||
StatusPreconditionFailed = 412 // Precondition Failed
|
||||
// RFC 9110, 15.5.14
|
||||
StatusRequestEntityTooLarge = 413 // Request Entity Too Large
|
||||
// RFC 9110, 15.5.15
|
||||
StatusRequestURITooLong = 414 // Request URI Too Long
|
||||
// RFC 9110, 15.5.16
|
||||
StatusUnsupportedMediaType = 415 // Unsupported Media Type
|
||||
// RFC 9110, 15.5.17
|
||||
StatusRequestedRangeNotSatisfiable = 416 // Requested Range Not Satisfiable
|
||||
// RFC 9110, 15.5.18
|
||||
StatusExpectationFailed = 417 // Expectation Failed
|
||||
// RFC 9110, 15.5.19 (Unused)
|
||||
StatusTeapot = 418 // I'm a teapot
|
||||
// RFC 9110, 15.5.20
|
||||
StatusMisdirectedRequest = 421 // Misdirected Request
|
||||
// RFC 9110, 15.5.21
|
||||
StatusUnprocessableEntity = 422 // Unprocessable Entity
|
||||
// RFC 4918, 11.3
|
||||
StatusLocked = 423 // Locked
|
||||
// RFC 4918, 11.4
|
||||
StatusFailedDependency = 424 // Failed Dependency
|
||||
// RFC 8470, 5.2.
|
||||
StatusTooEarly = 425 // Too Early
|
||||
// RFC 9110, 15.5.22
|
||||
StatusUpgradeRequired = 426 // Upgrade Required
|
||||
// RFC 6585, 3
|
||||
StatusPreconditionRequired = 428 // Precondition Required
|
||||
// RFC 6585, 4
|
||||
StatusTooManyRequests = 429 // Too Many Requests
|
||||
// RFC 6585, 5
|
||||
StatusRequestHeaderFieldsTooLarge = 431 // Request Header Fields Too Large
|
||||
// RFC 7725, 3
|
||||
StatusUnavailableForLegalReasons = 451 // Unavailable For Legal Reasons
|
||||
|
||||
// RFC 9110, 15.6.1
|
||||
StatusInternalServerError = 500 // Internal Server Error
|
||||
// RFC 9110, 15.6.2
|
||||
StatusNotImplemented = 501 // Not Implemented
|
||||
// RFC 9110, 15.6.3
|
||||
StatusBadGateway = 502 // Bad Gateway
|
||||
// RFC 9110, 15.6.4
|
||||
StatusServiceUnavailable = 503 // Service Unavailable
|
||||
// RFC 9110, 15.6.5
|
||||
StatusGatewayTimeout = 504 // Gateway Timeout
|
||||
// RFC 9110, 15.6.6
|
||||
StatusHTTPVersionNotSupported = 505 // HTTP Version Not Supported
|
||||
// RFC 2295, 8.1
|
||||
StatusVariantAlsoNegotiates = 506 // Variant Also Negotiates
|
||||
// RFC 4918, 11.5
|
||||
StatusInsufficientStorage = 507 // Insufficient Storage
|
||||
// RFC 5842, 7.2
|
||||
StatusLoopDetected = 508 // Loop Detected
|
||||
// RFC 2774, 7
|
||||
StatusNotExtended = 510 // Not Extended
|
||||
// RFC 6585, 6
|
||||
StatusNetworkAuthenticationRequired = 511 // Network Authentication Required
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
// Code generated by "stringer -type Method -linecomment -output stringers.go"; DO NOT EDIT.
|
||||
|
||||
package httphi
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[MethUndefined-0]
|
||||
_ = x[MethGet-1]
|
||||
_ = x[MethHead-2]
|
||||
_ = x[MethPost-3]
|
||||
_ = x[MethPut-4]
|
||||
_ = x[MethPatch-5]
|
||||
_ = x[MethDelete-6]
|
||||
_ = x[MethConnect-7]
|
||||
_ = x[MethOptions-8]
|
||||
_ = x[MethTrace-9]
|
||||
_ = x[MethUnknown-10]
|
||||
}
|
||||
|
||||
const _Method_name = "undefinedGETHEADPOSTPUTPATCHDELETECONNECTOPTIONSTRACEunknown"
|
||||
|
||||
var _Method_index = [...]uint8{0, 9, 12, 16, 20, 23, 28, 34, 41, 48, 53, 60}
|
||||
|
||||
func (i Method) String() string {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_Method_index)-1 {
|
||||
return "Method(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Method_name[_Method_index[idx]:_Method_index[idx+1]]
|
||||
}
|
||||
+45
-63
@@ -4,108 +4,92 @@ import (
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// Cookie implements cookie key-value parsing. Methods function similarly to eponymous [Header] methods.
|
||||
// Cookie implements cookie key-value parsing. Methods function similarly to eponymous [HeaderV1] methods.
|
||||
// Cookie represents a single-line Cookie header value in a HTTP header, much like the standard library Cookie.
|
||||
type Cookie struct {
|
||||
buf []byte
|
||||
kvs []argsKV // first key-value pair is the data Key/Value pair.
|
||||
kv kvBuffer
|
||||
}
|
||||
|
||||
// Reset functions very similarly to [Header.Reset]. Can be used for in-place cookie parsing.
|
||||
func (c *Cookie) Reset(buf []byte) {
|
||||
if buf == nil {
|
||||
buf = c.buf[:0]
|
||||
}
|
||||
*c = Cookie{
|
||||
buf: buf,
|
||||
kvs: c.kvs[:0],
|
||||
}
|
||||
// EnableBufferGrowth allows the cookie's buffer to grow past what [Cookie.Reset] was
|
||||
// handed. See [kvBuffer.EnableBufferGrowth].
|
||||
func (c *Cookie) EnableBufferGrowth(enableBufferGrowth bool) {
|
||||
c.kv.EnableBufferGrowth(enableBufferGrowth)
|
||||
}
|
||||
|
||||
// Reset functions very similarly to [HeaderV1.Reset]. Can be used for in-place cookie parsing.
|
||||
func (c *Cookie) Reset(buf []byte, capKV int) { c.kv.Reset(buf, capKV) }
|
||||
|
||||
func (c *Cookie) valid() bool {
|
||||
return len(c.kv.kvs) > 0 && c.kv.kvs[0].key.len > 0
|
||||
}
|
||||
|
||||
// Name returns the first cookie key which is commonly referred to as the cookie's name. Returns nil if not found.
|
||||
func (c *Cookie) Name() []byte {
|
||||
if len(c.kvs) == 0 || c.kvs[0].key.len == 0 {
|
||||
if !c.valid() {
|
||||
return nil
|
||||
}
|
||||
return tok2bytes(c.buf, c.kvs[0].key)
|
||||
return c.kv.AtKey(0)
|
||||
}
|
||||
|
||||
// Value returns the first cookie value associated with the name. Returns nil if not found.
|
||||
func (c *Cookie) Value() []byte {
|
||||
if len(c.kvs) == 0 || c.kvs[0].value.len == 0 {
|
||||
if !c.valid() {
|
||||
return nil
|
||||
}
|
||||
return tok2bytes(c.buf, c.kvs[0].value)
|
||||
return c.kv.AtValue(0)
|
||||
}
|
||||
|
||||
// ParseBytes copies the argument bytes to the Cookie's underlying buffer and parses the cookie.
|
||||
func (c *Cookie) ParseBytes(cookie []byte) error {
|
||||
c.Reset(nil)
|
||||
c.buf = append(c.buf[:0], cookie...)
|
||||
c.Reset(nil, 0)
|
||||
c.kv.buf = append(c.kv.buf[:0], cookie...)
|
||||
return c.Parse()
|
||||
}
|
||||
|
||||
// CopyFrom makes a copy of the argument cookie to the receiver dst argument. No memory is shared between cookies.
|
||||
func (dst *Cookie) CopyFrom(c Cookie) {
|
||||
dst.buf = append(dst.buf[:0], c.buf...)
|
||||
dst.kvs = append(dst.kvs[:0], c.kvs...)
|
||||
}
|
||||
func (dst *Cookie) CopyFrom(c Cookie) { dst.kv.CopyFrom(&c.kv) }
|
||||
|
||||
// Parse parses the cookie's buffer in place.
|
||||
func (c *Cookie) Parse() error {
|
||||
if len(c.kvs) > 0 {
|
||||
if c.kv.Len() > 0 {
|
||||
return errCookiesParsed
|
||||
}
|
||||
off := 0
|
||||
for {
|
||||
k, v, n := parseCookie(c.buf[off:])
|
||||
k, v, n := parseCookie(c.kv.buf[off:])
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
c.kvs = append(c.kvs, argsKV{
|
||||
key: bytes2tok(c.buf, k),
|
||||
value: bytes2tok(c.buf, v),
|
||||
})
|
||||
if !c.kv.setInternal(k, v) {
|
||||
return ErrBufferExhausted
|
||||
}
|
||||
|
||||
off += n
|
||||
}
|
||||
if len(c.kvs) == 0 {
|
||||
if c.kv.Len() == 0 {
|
||||
return errNoCookies
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cookie) ForEach(cb func(key, value []byte) error) error {
|
||||
nc := len(c.kvs)
|
||||
for i := range nc {
|
||||
kv := c.kvs[i]
|
||||
key := tok2bytes(c.buf, kv.key)
|
||||
value := tok2bytes(c.buf, kv.value)
|
||||
err := cb(key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// ForEach iterates over the cookie's key-value pairs, stopping on the first
|
||||
// error returned by cb and returning it.
|
||||
func (c *Cookie) ForEach(cb func(key, value []byte) bool) {
|
||||
c.kv.ForEach(cb)
|
||||
}
|
||||
|
||||
// Get gets a cookie's value from its key. Use HasValueOrKey to check if a key or single-valued cookie is present in the cookie.
|
||||
func (c *Cookie) Get(key string) []byte {
|
||||
nc := len(c.kvs)
|
||||
for i := range nc {
|
||||
kv := c.kvs[i]
|
||||
if b2s(tok2bytes(c.buf, kv.key)) == key {
|
||||
return tok2bytes(c.buf, kv.value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Cookie) Get(key string) []byte { return c.kv.Get(key) }
|
||||
|
||||
// HasKeyOrSingleValue returns true if the cookie contains a pair with the given
|
||||
// key or a valueless attribute with the given text, i.e: "Secure" or "HttpOnly".
|
||||
// It cannot defer to [KVBuffer.Present]: parseCookie stores a valueless
|
||||
// attribute with an empty key and the text as the value, so a key-only lookup
|
||||
// would never match one.
|
||||
func (c *Cookie) HasKeyOrSingleValue(keyOrSingleValue string) bool {
|
||||
nc := len(c.kvs)
|
||||
for i := range nc {
|
||||
kv := c.kvs[i]
|
||||
if kv.key.len == 0 && b2s(tok2bytes(c.buf, kv.value)) == keyOrSingleValue ||
|
||||
b2s(tok2bytes(c.buf, kv.key)) == keyOrSingleValue {
|
||||
for i, nc := 0, c.kv.Len(); i < nc; i++ {
|
||||
k, v := c.kv.At(i)
|
||||
if (len(k) == 0 && b2s(v) == keyOrSingleValue) || b2s(k) == keyOrSingleValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -158,16 +142,14 @@ func (c *Cookie) String() string {
|
||||
|
||||
// AppendKeyValues appends the HTTP header value of the cookie expected after the "Cookie:" string. Does not include trailing \r\n's.
|
||||
func (c *Cookie) AppendKeyValues(dst []byte) []byte {
|
||||
nc := len(c.kvs)
|
||||
nc := c.kv.Len()
|
||||
for i := range nc {
|
||||
kv := c.kvs[i]
|
||||
key := tok2bytes(c.buf, kv.key)
|
||||
value := tok2bytes(c.buf, kv.value)
|
||||
if len(key) != 0 {
|
||||
dst = append(dst, key...)
|
||||
k, v := c.kv.At(i)
|
||||
if len(k) != 0 {
|
||||
dst = append(dst, k...)
|
||||
dst = append(dst, '=')
|
||||
}
|
||||
dst = append(dst, value...)
|
||||
dst = append(dst, v...)
|
||||
if i+1 < nc {
|
||||
dst = append(dst, ';', ' ')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Form holds "application/x-www-form-urlencoded" key-value pairs, the encoding
|
||||
// HTML forms use for POST bodies and query strings alike. Methods function
|
||||
// similarly to eponymous [Cookie] methods.
|
||||
//
|
||||
// Pairs are stored as they appear on the wire, percent-encoded and with '+'
|
||||
// undecoded, until [Form.Decode] rewrites them in place. The caller bounds the
|
||||
// data: Form parses the buffer it is handed and reads nothing more.
|
||||
type Form struct {
|
||||
kv kvBuffer
|
||||
}
|
||||
|
||||
// EnableBufferGrowth allows the form's buffer to grow past what [Form.Reset] was
|
||||
// handed. See [kvBuffer.EnableBufferGrowth].
|
||||
func (f *Form) EnableBufferGrowth(enableGrowth bool) { f.kv.EnableBufferGrowth(enableGrowth) }
|
||||
|
||||
// Reset discards parsed pairs and sets the buffer to parse in place.
|
||||
// If buf is nil the current buffer is reused.
|
||||
//
|
||||
// capKV sizes the pair table. With growth disabled it is a hard limit, so a
|
||||
// capKV of 0 leaves no room for a single pair and [Form.Parse] answers
|
||||
// [ErrBufferExhausted]; size it to the pairs expected.
|
||||
func (f *Form) Reset(buf []byte, capKV int) {
|
||||
f.kv.Reset(buf, capKV)
|
||||
}
|
||||
|
||||
// ReadFromBytes appends buf to the underlying buffer, accumulating data to parse. Returns ErrBufferExhausted when buf does not fit and growth is disabled.
|
||||
func (f *Form) ReadFromBytes(b []byte) error { return f.kv.ReadFromBytes(b) }
|
||||
|
||||
// BufferUsed returns bytes accumulated by the Read* methods and awaiting a
|
||||
// [Form.Parse]. See [kvBuffer.BufferUsed].
|
||||
func (f *Form) BufferUsed() int { return f.kv.BufferUsed() }
|
||||
|
||||
// ReadLimited appends at most limit bytes read from r to the underlying buffer. A read returning data alongside io.EOF reports a nil error, later ones io.EOF.
|
||||
func (f *Form) ReadLimited(r io.Reader, limit int) (int, error) { return f.kv.ReadLimited(r, limit) }
|
||||
|
||||
// ParseBytes copies the argument bytes to the Form's underlying buffer and parses them.
|
||||
func (f *Form) ParseBytes(b []byte) error {
|
||||
f.Reset(nil, 0)
|
||||
if len(b) == 0 {
|
||||
return nil // An empty body is an empty form, not a failure to read one.
|
||||
}
|
||||
err := f.kv.ReadFromBytes(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Parse()
|
||||
}
|
||||
|
||||
// Parse parses the form's buffer in place.
|
||||
func (f *Form) Parse() error {
|
||||
f.kv.discardKVs()
|
||||
key, value, rest := NextQueryPair(f.kv.buf)
|
||||
for key != nil {
|
||||
if !f.kv.setInternal(key, value) {
|
||||
return ErrBufferExhausted
|
||||
}
|
||||
key, value, rest = NextQueryPair(rest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode rewrites every key and value in place, replacing percent escapes and
|
||||
// '+' with the bytes they encode. Decoding only shrinks, so no memory is added.
|
||||
func (f *Form) Decode() error {
|
||||
const plusAsSpace = true // Form encoded data, unlike a path.
|
||||
nkvs := f.kv.Len()
|
||||
for i := range nkvs {
|
||||
k, v := f.kv.At(i)
|
||||
nk, err := CopyDecodedPercentURL(k, k, plusAsSpace)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(v) == 0 {
|
||||
if nk != len(k) {
|
||||
f.kv.setAt(i, k[:nk], v) // k[:nk]: the decoded key is shorter.
|
||||
}
|
||||
continue
|
||||
}
|
||||
nv, err := CopyDecodedPercentURL(v, v, plusAsSpace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nk != len(k) || nv != len(v) {
|
||||
f.kv.setAt(i, k[:nk], v[:nv])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len returns the amount of key-value pairs parsed.
|
||||
func (f *Form) Len() int { return f.kv.Len() }
|
||||
|
||||
// Pair returns the i'th key-value pair in wire order. The value is nil for a
|
||||
// pair with no '=', i.e: "ok" in "ok&q=go", which distinguishes it from "ok="
|
||||
// where the value is present and empty.
|
||||
func (f *Form) Pair(i int) (key, value []byte) {
|
||||
return f.kv.At(i)
|
||||
}
|
||||
|
||||
// Get returns the value of the first pair matching key, nil if absent or if the
|
||||
// pair has no value. Bytes are compared as stored, so call [Form.Decode] first
|
||||
// when keys may be encoded.
|
||||
func (f *Form) Get(key string) []byte { return f.kv.Get(key) }
|
||||
|
||||
// Has returns true if key is present, with or without a value.
|
||||
func (f *Form) Has(key string) bool { return f.kv.Present(key) }
|
||||
|
||||
// AppendKeyValues appends the form's wire representation to dst and returns it.
|
||||
func (f *Form) AppendKeyValues(dst []byte) []byte {
|
||||
nkv := f.kv.Len()
|
||||
for i := range nkv {
|
||||
key, value := f.Pair(i)
|
||||
if i > 0 {
|
||||
dst = append(dst, '&')
|
||||
}
|
||||
dst = append(dst, key...)
|
||||
if value != nil {
|
||||
dst = append(dst, '=')
|
||||
dst = append(dst, value...)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// NextQueryPair splits the leading key-value pair off a query string and returns
|
||||
// what remains of it. Loop until rawkey is nil:
|
||||
//
|
||||
// rawkey, rawval, rest := httpraw.NextQueryPair(h.RequestQuery())
|
||||
// for rawkey != nil {
|
||||
// // use rawkey, rawval.
|
||||
// rawkey, rawval, rest = httpraw.NextQueryPair(rest)
|
||||
// }
|
||||
//
|
||||
// A pair with no '=' yields a nil rawval, i.e: "debug" in "?debug&q=go", which
|
||||
// distinguishes it from "?debug=" where the value is present and empty. Empty
|
||||
// sequences are skipped, so "?&&q=go&" yields a single pair. Only '&' separates
|
||||
// pairs and only the first '=' splits a pair.
|
||||
func NextQueryPair(query []byte) (rawkey, rawval, rest []byte) {
|
||||
for len(query) > 0 {
|
||||
pair := query
|
||||
amp := bytes.IndexByte(query, '&')
|
||||
if amp >= 0 {
|
||||
pair, query = query[:amp], query[amp+1:]
|
||||
} else {
|
||||
query = nil
|
||||
}
|
||||
if len(pair) == 0 {
|
||||
continue // Empty sequence, see WHATWG URL urlencoded parsing.
|
||||
}
|
||||
if before, after, ok := bytes.Cut(pair, []byte{'='}); ok {
|
||||
return before, after, query
|
||||
}
|
||||
return pair, nil, query
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// render joins a form's pairs as "key=value", a valueless key as "key".
|
||||
func render(f *Form) string {
|
||||
var sb strings.Builder
|
||||
for i := range f.Len() {
|
||||
key, value := f.Pair(i)
|
||||
if i > 0 {
|
||||
sb.WriteByte('|')
|
||||
}
|
||||
sb.Write(key)
|
||||
if value != nil {
|
||||
sb.WriteByte('=')
|
||||
sb.Write(value)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestFormParse(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{body: "", want: ""},
|
||||
{body: "q=go", want: "q=go"},
|
||||
{body: "name=Jos%C3%A9+P%C3%A9rez&msg=hi+there&ok=on", want: "name=Jos%C3%A9+P%C3%A9rez|msg=hi+there|ok=on"},
|
||||
{body: "ok", want: "ok"}, // Flag: no '=' at all.
|
||||
{body: "ok=", want: "ok="}, // Present but empty.
|
||||
{body: "&&q=go&", want: "q=go"}, // Empty sequences skipped.
|
||||
{body: "tag=a&tag=b", want: "tag=a|tag=b"}, // Duplicates kept in order.
|
||||
{body: "=v", want: "=v"}, // Empty name kept.
|
||||
{body: "a=b=c", want: "a=b=c"}, // Only the first '=' splits.
|
||||
} {
|
||||
var f Form
|
||||
if err := f.ParseBytes([]byte(test.body)); err != nil {
|
||||
t.Fatalf("%q: %s", test.body, err)
|
||||
}
|
||||
if got := render(&f); got != test.want {
|
||||
t.Errorf("%q: want %q, got %q", test.body, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode rewrites keys and values in place: percent escapes and '+' as space.
|
||||
func TestFormDecode(t *testing.T) {
|
||||
var f Form
|
||||
const body = "name=Jos%C3%A9+P%C3%A9rez&a%20b=c%2Bd&ok"
|
||||
if err := f.ParseBytes([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Decode(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "name=José Pérez|a b=c+d|ok"
|
||||
if got := render(&f); got != want {
|
||||
t.Errorf("want %q, got %q", want, got)
|
||||
}
|
||||
if got := string(f.Get("a b")); got != "c+d" {
|
||||
t.Errorf("want decoded key lookup %q, got %q", "c+d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Decoding a valueless key that shrinks must rewrite the key without inventing
|
||||
// a value: the pair has no '=' before Decode and must have none after.
|
||||
func TestFormDecodeValuelessKeyShrinks(t *testing.T) {
|
||||
var f Form
|
||||
const body = "a=1&o%6Bay"
|
||||
if err := f.ParseBytes([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Decode(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "a=1|okay"
|
||||
if got := render(&f); got != want {
|
||||
t.Errorf("want %q, got %q", want, got)
|
||||
}
|
||||
if _, value := f.Pair(1); value != nil {
|
||||
t.Errorf("want valueless pair to stay valueless, got value %q", value)
|
||||
}
|
||||
if !f.Has("okay") {
|
||||
t.Error("want decoded valueless key present")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed escape must be reported, never silently passed through.
|
||||
func TestFormDecodeMalformed(t *testing.T) {
|
||||
for _, body := range []string{"q=%zz", "%zz=v", "q=%4"} {
|
||||
var f Form
|
||||
if err := f.ParseBytes([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Decode(); err == nil {
|
||||
t.Errorf("%q: want decode error, got nil", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormGetHas(t *testing.T) {
|
||||
var f Form
|
||||
if err := f.ParseBytes([]byte("tag=a&tag=b&ok&empty=")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(f.Get("tag")); got != "a" {
|
||||
t.Errorf("want first value %q, got %q", "a", got)
|
||||
}
|
||||
if got := f.Get("ok"); got != nil {
|
||||
t.Errorf("want nil value for valueless key, got %q", got)
|
||||
}
|
||||
if got := f.Get("nope"); got != nil {
|
||||
t.Errorf("want nil for absent key, got %q", got)
|
||||
}
|
||||
if v := f.Get("empty"); v == nil || len(v) != 0 {
|
||||
t.Errorf("want present empty value, got %v", v)
|
||||
}
|
||||
for _, key := range []string{"tag", "ok", "empty"} {
|
||||
if !f.Has(key) {
|
||||
t.Errorf("want Has(%q) true", key)
|
||||
}
|
||||
}
|
||||
if f.Has("nope") {
|
||||
t.Error("want Has(nope) false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormAppendKeyValues(t *testing.T) {
|
||||
const body = "name=go&ok&empty=&tag=a&tag=b"
|
||||
var f Form
|
||||
if err := f.ParseBytes([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(f.AppendKeyValues(nil)); got != body {
|
||||
t.Errorf("want round trip %q, got %q", body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing into a reused Form must not allocate: the pair storage is reused.
|
||||
func TestFormParseReuseNoAlloc(t *testing.T) {
|
||||
body := []byte("name=go&tag=a&tag=b&ok")
|
||||
var f Form
|
||||
if err := f.ParseBytes(body); err != nil { // Warm up the pair storage.
|
||||
t.Fatal(err)
|
||||
}
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
f.Reset(body, 0) // 0 preserves the pair storage warmed up above, the reuse under test.
|
||||
f.Parse()
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("reused Form allocated %v times, want 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
// BufferUsed reports buffered bytes, not parsed pairs, so a caller appending
|
||||
// from several sources can tell whether a separator is needed before the next
|
||||
// one. Form.Len is zero until Parse runs and cannot answer that.
|
||||
func TestFormBufferUsed(t *testing.T) {
|
||||
var f Form
|
||||
f.Reset(nil, defaultKVCap)
|
||||
if got := f.BufferUsed(); got != 0 {
|
||||
t.Errorf("want 0 on a fresh form, got %d", got)
|
||||
}
|
||||
if err := f.ReadFromBytes([]byte("a=1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.BufferUsed(); got != 3 {
|
||||
t.Errorf("want 3 buffered, got %d", got)
|
||||
}
|
||||
if got := f.Len(); got != 0 {
|
||||
t.Errorf("Len must stay 0 until Parse, got %d", got)
|
||||
}
|
||||
// A second source appended behind a separator.
|
||||
if err := f.ReadFromBytes([]byte("&b=2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.BufferUsed(); got != 7 {
|
||||
t.Errorf("want 7 buffered, got %d", got)
|
||||
}
|
||||
if err := f.Parse(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := render(&f); got != "a=1|b=2" {
|
||||
t.Errorf("want a=1|b=2, got %q", got)
|
||||
}
|
||||
if got := f.BufferUsed(); got != 7 {
|
||||
t.Errorf("BufferUsed must not change on Parse, got %d", got)
|
||||
}
|
||||
// Reset discards the pairs and the buffered bytes with them.
|
||||
f.Reset(nil, defaultKVCap)
|
||||
if got := f.BufferUsed(); got != 0 {
|
||||
t.Errorf("want 0 after Reset, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"slices"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
methodGet = "GET"
|
||||
strHTTP11 = "HTTP/1.1"
|
||||
strCRLF = "\r\n"
|
||||
headerCookie = "Cookie"
|
||||
headerConnection = "Connection"
|
||||
strClose = "close"
|
||||
)
|
||||
|
||||
type flags uint16
|
||||
|
||||
const (
|
||||
flagNoBufferGrow flags = 1 << iota
|
||||
flagDoneParsingHeader
|
||||
flagOOMReached
|
||||
flagConnClose
|
||||
flagNoHTTP11
|
||||
flagMangledBuffer // set when header fields appended to buffer via Add,Set calls
|
||||
flagReaderEOF
|
||||
)
|
||||
|
||||
func (f flags) hasAny(checkThese flags) bool {
|
||||
return f&checkThese != 0
|
||||
}
|
||||
|
||||
// Header implements "raw" HTTP header key-value parsing, validation and marshalling.
|
||||
//
|
||||
// It does NOT implement:
|
||||
// - Normalization.
|
||||
// - Cookies (see [Cookie]).
|
||||
// - Special header optimizations.
|
||||
// - Content-Length validation and other special header field value validation.
|
||||
type Header struct {
|
||||
hbuf headerBuf
|
||||
|
||||
// Request fields.
|
||||
method headerSlice
|
||||
requestURI headerSlice
|
||||
proto headerSlice
|
||||
|
||||
// Response fields.
|
||||
statusCode headerSlice
|
||||
statusText headerSlice
|
||||
|
||||
flags flags
|
||||
_ noCopy
|
||||
}
|
||||
|
||||
// EnableBufferGrowth disables buffer growth during parsing if b is false. Is enabled by default.
|
||||
// Disabling buffer growth prevents allocations but methods may throw errors on insufficient memory.
|
||||
func (h *Header) EnableBufferGrowth(b bool) {
|
||||
if !b {
|
||||
h.flags |= flagNoBufferGrow
|
||||
} else {
|
||||
h.flags &^= flagNoBufferGrow
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBytes copies the bytes into buffer and parses the HTTP header. It fails if HTTP header data is incomplete.
|
||||
func (h *Header) ParseBytes(asResponse bool, b []byte) error {
|
||||
h.Reset(nil)
|
||||
h.hbuf.readFromBytes(b)
|
||||
return h.parse(asResponse)
|
||||
}
|
||||
|
||||
// Parse parses accumulated data in-place with no copying. One can set HTTP header data buffer with [Header.Reset].
|
||||
// It fails if HTTP data is incomplete.
|
||||
func (h *Header) Parse(asResponse bool) error {
|
||||
debuglog("http:parse:reset")
|
||||
h.Reset(h.hbuf.buf)
|
||||
debuglog("http:parse:start")
|
||||
return h.parse(asResponse)
|
||||
}
|
||||
|
||||
// TryParse begins parsing or resumes parsing from a failed previous attempt from any of the Parse* methods.
|
||||
// As long as needMoreData returns true future calls to TryParse may succeed and the header is not done parsing.
|
||||
// Users may call [Header.ForEach] in-between TryParse calls so as to validate values before header is completely parsed.
|
||||
//
|
||||
// needMoreData := true
|
||||
// var err error
|
||||
// for needMoreData {
|
||||
// _, err = h.ReadFrom(r, 1024)
|
||||
// if err != nil {
|
||||
// break
|
||||
// }
|
||||
// needMoreData, err = h.TryParse(asResponse)
|
||||
// }
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
func (h *Header) TryParse(asResponse bool) (needMoreData bool, err error) {
|
||||
if h.flags.hasAny(flagDoneParsingHeader) {
|
||||
return false, errAlreadyParsed
|
||||
} else if h.flags.hasAny(flagMangledBuffer) {
|
||||
return false, errMangledBuffer
|
||||
}
|
||||
if asResponse && h.statusCode.len == 0 || !asResponse && h.requestURI.start == 0 {
|
||||
err = h.parseFirstLine(asResponse)
|
||||
if err != nil {
|
||||
return err == errNeedMore, err
|
||||
}
|
||||
}
|
||||
err = h.parseNextHeaders()
|
||||
return err == errNeedMore, err
|
||||
}
|
||||
|
||||
// ParsingSuccess returns true if TryParse was successful, that is to say it returned needMoreData==false and err==nil.
|
||||
func (h *Header) ParsingSuccess() bool {
|
||||
return h.flags.hasAny(flagDoneParsingHeader)
|
||||
}
|
||||
|
||||
// ReadFromLimited reads at most maxBytesToRead from reader and appends them to underlying buffer.
|
||||
// Used to accumulate HTTP header for later parsing with [Header.TryParse].
|
||||
// If read is successful (read length>0) and reader returns [io.EOF] then ReadFromLimited will return a nil error.
|
||||
func (h *Header) ReadFromLimited(r io.Reader, maxBytesToRead int) (int, error) {
|
||||
if maxBytesToRead <= 0 {
|
||||
return 0, errSmallBuffer
|
||||
} else if h.flags.hasAny(flagMangledBuffer) {
|
||||
return 0, errMangledBuffer
|
||||
}
|
||||
free := h.BufferFree()
|
||||
if free < maxBytesToRead {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
return 0, errSmallBuffer
|
||||
}
|
||||
h.hbuf.buf = slices.Grow(h.hbuf.buf, maxBytesToRead)
|
||||
}
|
||||
blen := len(h.hbuf.buf)
|
||||
b := h.hbuf.buf[blen:min(blen+maxBytesToRead, cap(h.hbuf.buf))]
|
||||
n, err := r.Read(b)
|
||||
if err != nil && err == io.EOF {
|
||||
h.flags |= flagReaderEOF
|
||||
if n > 0 {
|
||||
err = nil // Nil-out error if read was succesful so as to not spook readers.
|
||||
}
|
||||
}
|
||||
h.hbuf.buf = h.hbuf.buf[:blen+n]
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadFromBytes appends argument buffer to underlying buffer.
|
||||
// Used to accumulate HTTP header for later parsing with [Header.TryParse].
|
||||
func (h *Header) ReadFromBytes(b []byte) (int, error) {
|
||||
if len(b) == 0 {
|
||||
return 0, errSmallBuffer
|
||||
}
|
||||
free := h.BufferFree()
|
||||
if free < len(b) {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
return 0, errSmallBuffer
|
||||
}
|
||||
h.hbuf.buf = slices.Grow(h.hbuf.buf, len(b))
|
||||
}
|
||||
h.hbuf.readFromBytes(b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// BufferReceived returns the amoung of bytes read during calls to Read* methods.
|
||||
func (h *Header) BufferReceived() int {
|
||||
if h.flags.hasAny(flagMangledBuffer | flagOOMReached) {
|
||||
return 0
|
||||
}
|
||||
return len(h.hbuf.buf)
|
||||
}
|
||||
|
||||
// BufferParsed returns the amount of bytes parsed during a call to Parse* methods.
|
||||
// If the Parse* method completed without error then BufferParsed returns the header's length including the final "\r\n\r\n" text.
|
||||
// BufferParsed returns 0 if the buffer is invalid/mangled or if no header data has been parsed succesfully.
|
||||
func (h *Header) BufferParsed() int {
|
||||
if h.flags.hasAny(flagMangledBuffer | flagOOMReached) {
|
||||
return 0
|
||||
}
|
||||
return h.hbuf.off
|
||||
}
|
||||
|
||||
// BufferFree returns amount of bytes free in underlying buffer.
|
||||
func (h *Header) BufferFree() int {
|
||||
return h.hbuf.free()
|
||||
}
|
||||
|
||||
// BufferCapacity returns the total capacity of the underlying buffer.
|
||||
func (h *Header) BufferCapacity() int {
|
||||
return cap(h.hbuf.buf)
|
||||
}
|
||||
|
||||
// ForEach iterates over header key-value field tuples.
|
||||
func (h *Header) ForEach(cb func(key, value []byte) error) error {
|
||||
return h.hbuf.forEach(cb)
|
||||
}
|
||||
|
||||
func (hb *headerBuf) forEach(cb func(key, value []byte) error) error {
|
||||
nh := len(hb.headers)
|
||||
for i := range nh {
|
||||
kv := hb.headers[i]
|
||||
if !kv.isValid() {
|
||||
continue
|
||||
}
|
||||
key := hb.musttoken(kv.key)
|
||||
value := hb.musttoken(kv.value)
|
||||
err := cb(key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset discards all parsed data and sets the buffer data to buf. This method
|
||||
// can be used to avoid copying and growing buffers. Call [Header.Parse] after setting buffer
|
||||
// data with Reset to parse data in-place.
|
||||
// If buf is nil then the current buffer is reused. There are 3 ways to use Reset:
|
||||
//
|
||||
// h.Reset(prealloc[:0]); h.ParseBytes(httpHeader) // Tell header to use a pre-allocated buffer capacity.
|
||||
// h.Reset(httpHeader); h.Parse() // Parse bytes in place with no copying.
|
||||
// h.Reset(nil) // Reuse buffer previously set in a call to Reset.
|
||||
func (h *Header) Reset(buf []byte) {
|
||||
if h.flags.hasAny(flagNoBufferGrow) && len(buf) < 32 {
|
||||
panic("small buffer and flagNoBufferGrow set")
|
||||
}
|
||||
const persistentFlags = flagNoBufferGrow
|
||||
debuglog("http:reset:hbuf")
|
||||
h.hbuf.reset(buf)
|
||||
*h = Header{
|
||||
hbuf: h.hbuf,
|
||||
flags: h.flags & persistentFlags,
|
||||
}
|
||||
debuglog("http:reset:done")
|
||||
}
|
||||
|
||||
// Body returns the surplus data following headers. It is only valid as long as Parse* or Reset methods are not called.
|
||||
func (h *Header) Body() ([]byte, error) {
|
||||
debuglog("http:body")
|
||||
if h.flags.hasAny(flagMangledBuffer) {
|
||||
return nil, errMangledBuffer
|
||||
} else if h.flags.hasAny(flagDoneParsingHeader) {
|
||||
return h.hbuf.buf[h.hbuf.off:], nil
|
||||
}
|
||||
return nil, errUnparsed
|
||||
}
|
||||
|
||||
// SetBytes is equivalent to [Header.Set] but with a []byte value. Does not keep reference to value slice.
|
||||
// Calling SetBytes Mangles the buffer.
|
||||
func (h *Header) SetBytes(key string, value []byte) {
|
||||
h.Set(key, unsafe.String(&value[0], len(value)))
|
||||
}
|
||||
|
||||
// Set sets a key-value pair in the HTTP header.
|
||||
// Calling Set mangles the buffer.
|
||||
func (h *Header) Set(key, value string) {
|
||||
hb := &h.hbuf
|
||||
var useKv *argsKV
|
||||
for i := len(hb.headers); len(hb.headers) > 0 && i <= 0; i++ {
|
||||
// Search for key-value with largest buffer for value to store value reusing buffer.
|
||||
gotkv := &hb.headers[i]
|
||||
if b2s(hb.musttoken(gotkv.key)) == key {
|
||||
if useKv == nil {
|
||||
useKv = gotkv
|
||||
} else if gotkv.value.len > useKv.value.len {
|
||||
useKv.invalidate()
|
||||
useKv = gotkv
|
||||
} else {
|
||||
gotkv.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
if useKv == nil {
|
||||
h.appendHeader(key, value)
|
||||
} else {
|
||||
useKv.value = h.reuseOrAppend(useKv.value, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Get gets the first value of a key found in the headers. Use [Header.ForEach] to find multiple values corresponding to same key.
|
||||
func (h *Header) Get(key string) []byte {
|
||||
debuglog("http:get:start")
|
||||
kv := h.peekHeader(key)
|
||||
if kv.isValid() {
|
||||
debuglog("http:get:found")
|
||||
return h.hbuf.musttoken(kv.value)
|
||||
}
|
||||
debuglog("http:get:notfound")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add adds a new key-value pair to the HTTP header. Calling Add mangles the buffer.
|
||||
func (h *Header) Add(key, value string) {
|
||||
h.appendHeader(key, value)
|
||||
}
|
||||
|
||||
// Method returns HTTP request method.
|
||||
func (h *Header) Method() []byte {
|
||||
return h.getNonEmptyValue(h.method)
|
||||
}
|
||||
|
||||
// SetMethod sets the request header's method.
|
||||
func (h *Header) SetMethod(method string) {
|
||||
h.method = h.reuseOrAppend(h.method, method)
|
||||
}
|
||||
|
||||
// SetRequestURI sets RequestURI for the first HTTP request line.
|
||||
func (h *Header) SetRequestURI(requestURI string) {
|
||||
h.requestURI = h.reuseOrAppend(h.requestURI, requestURI)
|
||||
}
|
||||
|
||||
// RequestURI returns RequestURI from the first HTTP request line.
|
||||
func (h *Header) RequestURI() []byte {
|
||||
return h.getNonEmptyValue(h.requestURI)
|
||||
}
|
||||
|
||||
// Protocol returns the request header's HTTP protocol. Usually "HTTP/1.1".
|
||||
func (h *Header) Protocol() []byte {
|
||||
return h.getNonEmptyValue(h.proto)
|
||||
}
|
||||
|
||||
// SetProtocol sets the request header's protocol. Usually "HTTP/1.1".
|
||||
func (h *Header) SetProtocol(protocol string) {
|
||||
h.proto = h.reuseOrAppend(h.proto, protocol)
|
||||
}
|
||||
|
||||
// Status returns the response header's status code and status text. i.e: "200" "OK".
|
||||
func (h *Header) Status() (code, statusText []byte) {
|
||||
if h.statusCode.len == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.hbuf.musttoken(h.statusCode), h.hbuf.musttoken(h.statusText)
|
||||
}
|
||||
|
||||
// Status sets the response header's status code and status text. i.e: "200" "OK".
|
||||
func (h *Header) SetStatus(code, statusText string) {
|
||||
h.statusCode = h.reuseOrAppend(h.statusCode, code)
|
||||
h.statusText = h.reuseOrAppend(h.statusText, statusText)
|
||||
}
|
||||
|
||||
func (h *Header) getNonEmptyValue(s headerSlice) []byte {
|
||||
if s.len == 0 {
|
||||
return nil // If empty then value is invalid, return nil.
|
||||
}
|
||||
return h.hbuf.musttoken(s)
|
||||
}
|
||||
|
||||
// AppendRequest appends the request header representation to the buffer and returns the result.
|
||||
func (h *Header) AppendRequest(dst []byte) ([]byte, error) {
|
||||
proto := h.Protocol()
|
||||
if h.flags.hasAny(flagOOMReached) {
|
||||
return dst, errOOM
|
||||
} else if h.requestURI.len == 0 || h.method.len == 0 {
|
||||
return dst, errNeedMethodURI
|
||||
} else if len(proto) == 0 {
|
||||
return dst, errNoProto
|
||||
}
|
||||
|
||||
method := h.Method()
|
||||
if len(method) == 0 {
|
||||
dst = append(dst, methodGet...)
|
||||
} else {
|
||||
dst = append(dst, method...)
|
||||
}
|
||||
uri := h.RequestURI()
|
||||
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, uri...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, proto...)
|
||||
dst = append(dst, strCRLF...)
|
||||
|
||||
dst = h.AppendHeaders(dst)
|
||||
|
||||
return append(dst, strCRLF...), nil
|
||||
}
|
||||
|
||||
// AppendResponse appends the response header representation to the buffer and returns the result.
|
||||
func (h *Header) AppendResponse(dst []byte) ([]byte, error) {
|
||||
proto := h.Protocol()
|
||||
if h.flags.hasAny(flagOOMReached) {
|
||||
return dst, errOOM
|
||||
} else if h.statusCode.len == 0 || h.statusText.len == 0 {
|
||||
return dst, errBadStatusCodeTxt
|
||||
} else if len(proto) == 0 {
|
||||
return dst, errNoProto
|
||||
}
|
||||
code, text := h.Status()
|
||||
|
||||
dst = append(dst, proto...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, code...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, text...)
|
||||
dst = append(dst, strCRLF...)
|
||||
|
||||
dst = h.AppendHeaders(dst)
|
||||
|
||||
return append(dst, strCRLF...), nil
|
||||
}
|
||||
|
||||
// AppendHeaders appends headers to buffer. Use AppendRequest and AppendResponse over this.
|
||||
// Does not append extra \r\n to end. Appends nothing if contains no headers.
|
||||
func (h *Header) AppendHeaders(dst []byte) []byte {
|
||||
for i, n := 0, len(h.hbuf.headers); i < n; i++ {
|
||||
kv := &h.hbuf.headers[i]
|
||||
if kv.isValid() {
|
||||
key := h.hbuf.musttoken(kv.key)
|
||||
value := h.hbuf.musttoken(kv.value)
|
||||
dst = appendHeaderLine(dst, b2s(key), b2s(value))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (h *Header) String() string {
|
||||
buf, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
buf, err = h.AppendResponse(nil)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
return b2s(buf)
|
||||
}
|
||||
|
||||
func appendHeaderLine(dst []byte, key, value string) []byte {
|
||||
dst = append(dst, key...)
|
||||
dst = append(dst, ':', ' ')
|
||||
dst = append(dst, value...)
|
||||
return append(dst, strCRLF...)
|
||||
}
|
||||
|
||||
// Embed this type into a struct, which mustn't be copied,
|
||||
// so `go vet` gives a warning if this struct is copied.
|
||||
//
|
||||
// See https://github.com/golang/go/issues/8005#issuecomment-190753527 for details.
|
||||
// and also: https://stackoverflow.com/questions/52494458/nocopy-minimal-example
|
||||
type noCopy struct{}
|
||||
|
||||
func (*noCopy) Lock() {}
|
||||
func (*noCopy) Unlock() {}
|
||||
|
||||
// NormalizeKey normalizes a HTTP header key in-place. Returns true if buffer modified.
|
||||
// Examples of normalization:
|
||||
// - CONTENT -> Content
|
||||
// - content-length -> Content-Length
|
||||
// - cOnTeNt-LenGtH -> Content-Length
|
||||
func NormalizeHeaderKey(b []byte) (modified bool) {
|
||||
if len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
const asciiCapDiff = 'a' - 'A'
|
||||
for i := -1; i < len(b); i++ {
|
||||
nextToUpper := i == -1 || (b[i] == '-' && i < len(b)-1)
|
||||
if nextToUpper {
|
||||
i++
|
||||
isLower := b[i] >= 'a' && b[i] <= 'z'
|
||||
if isLower {
|
||||
modified = true
|
||||
b[i] -= asciiCapDiff
|
||||
}
|
||||
} else {
|
||||
isUpper := b[i] >= 'A' && b[i] <= 'Z'
|
||||
if isUpper {
|
||||
modified = true
|
||||
b[i] += asciiCapDiff
|
||||
}
|
||||
}
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
// CopyNormalizedHeaderValue copies the header value in the value buffer to dst.
|
||||
// The result may be shrunk. The target and source buffers can only alias if the
|
||||
// destination buffer 0 address is equal to value's 0 address.
|
||||
// Header value normalization implies the replacement of \r\n\t with a single space.
|
||||
func CopyNormalizedHeaderValue(dst []byte, value []byte) (n int, modified bool) {
|
||||
if len(dst) < len(value) {
|
||||
panic("httpraw.CopyNormalizedHeaderValue: dst buffer shorter than length")
|
||||
}
|
||||
write := 0
|
||||
read := 0
|
||||
for {
|
||||
rmStart := bytes.IndexByte(value[read:], '\n')
|
||||
if rmStart < 0 {
|
||||
write += copy(dst[write:], value[read:])
|
||||
break
|
||||
}
|
||||
omit := 1
|
||||
rmStart += read
|
||||
if rmStart+1 < len(value) && value[rmStart+1] == '\t' {
|
||||
omit++
|
||||
}
|
||||
if rmStart > 0 && value[rmStart-1] == '\r' {
|
||||
rmStart--
|
||||
omit++
|
||||
}
|
||||
modified = true
|
||||
n := copy(dst[write:], value[read:rmStart])
|
||||
dst[write+n] = ' '
|
||||
read += omit + n
|
||||
write += n + 1
|
||||
}
|
||||
return write, modified
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeaderParseRequest(t *testing.T) {
|
||||
const (
|
||||
wantMethod = "GET"
|
||||
wantURI = "/data/set"
|
||||
wantMessage = "hello world!"
|
||||
asRequest = false
|
||||
asResponse = true
|
||||
)
|
||||
req, err := http.NewRequest(wantMethod, wantURI, strings.NewReader(wantMessage))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantCookie http.Cookie
|
||||
wantCookie.SameSite = http.SameSiteLaxMode
|
||||
wantCookie.MaxAge = 360000
|
||||
wantCookie.Name = "key"
|
||||
wantCookie.Value = "value"
|
||||
wantCookie.Expires = time.Now().Add(time.Hour)
|
||||
wantCookie.Domain = "DOM"
|
||||
wantCookie.HttpOnly = true
|
||||
wantCookie.Secure = true
|
||||
wantCookie.Path = "/abc"
|
||||
req.Header.Set("Cookie", wantCookie.String())
|
||||
t.Log("valid cookie:", wantCookie.Valid() == nil, wantCookie.String())
|
||||
|
||||
var buf bytes.Buffer
|
||||
req.Write(&buf)
|
||||
var hdr Header
|
||||
msg := buf.Bytes()
|
||||
|
||||
start := time.Now()
|
||||
err = hdr.ParseBytes(asRequest, msg)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%s\nparsed in %s\n\n", msg, elapsed.String())
|
||||
if string(hdr.Method()) != wantMethod {
|
||||
t.Errorf("want method %s, got %q", wantMethod, hdr.Method())
|
||||
}
|
||||
if !bytes.Equal(hdr.RequestURI(), []byte(wantURI)) {
|
||||
t.Errorf("want request URI %q, got %q", wantURI, hdr.RequestURI())
|
||||
}
|
||||
contentLength, _ := strconv.Atoi(string(hdr.Get("Content-Length")))
|
||||
if contentLength != len(wantMessage) {
|
||||
t.Errorf("want Content-Length %d, got %d", len(wantMessage), contentLength)
|
||||
}
|
||||
var c Cookie
|
||||
cookie := hdr.Get("Cookie")
|
||||
c.Reset(cookie)
|
||||
err = c.Parse()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
key := string(c.Name())
|
||||
if key != wantCookie.Name {
|
||||
t.Errorf("want cookie key %q, got %q", wantCookie.Name, key)
|
||||
}
|
||||
value := string(c.Value())
|
||||
if value != wantCookie.Value {
|
||||
t.Errorf("want cookie key %q, got %q", wantCookie.Value, value)
|
||||
}
|
||||
domain := string(c.Get("Domain"))
|
||||
if domain != wantCookie.Domain {
|
||||
t.Errorf("want domain %q, got %q", wantCookie.Domain, domain)
|
||||
}
|
||||
httpOnly := c.HasKeyOrSingleValue("HttpOnly")
|
||||
if httpOnly != wantCookie.HttpOnly {
|
||||
t.Errorf("want cookie HttpOnly %v, got %v", wantCookie.HttpOnly, httpOnly)
|
||||
}
|
||||
secure := c.HasKeyOrSingleValue("Secure")
|
||||
if secure != wantCookie.Secure {
|
||||
t.Errorf("want cookie HttpOnly %v, got %v", wantCookie.Secure, secure)
|
||||
}
|
||||
samesite := string(c.Get("SameSite"))
|
||||
if samesite != strSameSite(wantCookie.SameSite) {
|
||||
t.Errorf("want cookie SameSite %v, got %v", strSameSite(wantCookie.SameSite), samesite)
|
||||
}
|
||||
body, err := hdr.Body()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if string(body) != wantMessage {
|
||||
t.Errorf("want body message %q, got %q", wantMessage, body)
|
||||
}
|
||||
cookieStr := string(c.AppendKeyValues(nil))
|
||||
if wantCookie.String() != cookieStr {
|
||||
t.Errorf("want full cookie representation\n%qgot:\n%q", wantCookie.String(), cookieStr)
|
||||
}
|
||||
data, _ := hdr.AppendRequest(nil)
|
||||
fmt.Printf("%s", data)
|
||||
}
|
||||
|
||||
func BenchmarkParseBytes(b *testing.B) {
|
||||
b.StopTimer()
|
||||
const (
|
||||
wantMethod = "GET"
|
||||
wantURI = "/data/set"
|
||||
wantMessage = "hello world!"
|
||||
asRequest = false
|
||||
)
|
||||
req, _ := http.NewRequest(wantMethod, wantURI, strings.NewReader(wantMessage))
|
||||
var rawBuf bytes.Buffer
|
||||
req.Write(&rawBuf)
|
||||
data := rawBuf.Bytes()
|
||||
|
||||
// hdr is declared outside the loop so that ParseBytes can reuse the
|
||||
// backing arrays on Reset (headers slice and data buffer) without
|
||||
// allocating on every iteration. Declaring it inside the loop causes
|
||||
// two allocs per iteration: one for the headers slice (make in reset)
|
||||
// and one for the data buffer (append in readFromBytes).
|
||||
var hdr Header
|
||||
b.StartTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := hdr.ParseBytes(asRequest, data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, err = hdr.Body()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strSameSite(mode http.SameSite) string {
|
||||
switch mode {
|
||||
case http.SameSiteLaxMode:
|
||||
return "Lax"
|
||||
case http.SameSiteDefaultMode:
|
||||
return ""
|
||||
case http.SameSiteStrictMode:
|
||||
return "Strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "None"
|
||||
default:
|
||||
panic("invalid same site")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderNormalizeKey(t *testing.T) {
|
||||
var tests = []struct {
|
||||
key string
|
||||
wantnorm string
|
||||
}{
|
||||
{key: "", wantnorm: ""},
|
||||
{key: "a-a-a", wantnorm: "A-A-A"},
|
||||
{key: "a-a-a-", wantnorm: "A-A-A-"},
|
||||
{key: "-", wantnorm: "-"},
|
||||
{key: "CONTENT", wantnorm: "Content"},
|
||||
{key: "cONTENT", wantnorm: "Content"},
|
||||
{key: "Content-Length", wantnorm: "Content-Length"},
|
||||
{key: "Content-length", wantnorm: "Content-Length"},
|
||||
{key: "content-length", wantnorm: "Content-Length"},
|
||||
{key: "conTent-lENgth", wantnorm: "Content-Length"},
|
||||
{key: "conTent-lENgth-", wantnorm: "Content-Length-"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
gotKey := []byte(test.key)
|
||||
modified := NormalizeHeaderKey(gotKey)
|
||||
if string(gotKey) != test.wantnorm {
|
||||
t.Errorf("mismatch want %q got %q", test.wantnorm, gotKey)
|
||||
}
|
||||
wantMod := string(gotKey) != test.key
|
||||
if wantMod != modified {
|
||||
t.Errorf("mismatch want mod=%v, got mod=%v", wantMod, modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyNormalizedHeaderValue(t *testing.T) {
|
||||
var tests = []struct {
|
||||
value string
|
||||
wantnorm string
|
||||
}{
|
||||
{value: "abc\r\n\tdef\r\n\tghi", wantnorm: "abc def ghi"},
|
||||
{value: "abc\r\n\tabc", wantnorm: "abc abc"},
|
||||
{value: "abc\n\tdef\n\tghi", wantnorm: "abc def ghi"},
|
||||
{value: "abc\n\tdef\n\tghi\n\t", wantnorm: "abc def ghi "},
|
||||
{value: "abc\n\tdef\n\tghi\r\n\t", wantnorm: "abc def ghi "},
|
||||
{value: "", wantnorm: ""},
|
||||
{value: "abc", wantnorm: "abc"},
|
||||
}
|
||||
dst := make([]byte, 256)
|
||||
for _, test := range tests {
|
||||
value := []byte(test.value)
|
||||
n, modified := CopyNormalizedHeaderValue(dst[:len(value)], value)
|
||||
got := dst[:n]
|
||||
if string(got) != test.wantnorm {
|
||||
t.Errorf("mismatch want %q got %q", test.wantnorm, got)
|
||||
}
|
||||
wantMod := string(got) != test.value
|
||||
if wantMod != modified {
|
||||
t.Errorf("mismatch want mod=%v, got mod=%v", wantMod, modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
methodGet = "GET"
|
||||
strHTTP1 = "HTTP/1."
|
||||
strHTTP11 = strHTTP1 + "1"
|
||||
strCRLF = "\r\n"
|
||||
headerCookie = "Cookie"
|
||||
headerConnection = "Connection"
|
||||
headerContentLength = "Content-Length"
|
||||
strClose = "close"
|
||||
strKeepAlive = "keep-alive"
|
||||
)
|
||||
|
||||
// Flags is a bitset of signals gathered while parsing or building a header,
|
||||
// such as a status code having been set or the peer requesting connection
|
||||
// close. See [HeaderV1.Flags].
|
||||
type Flags uint16
|
||||
|
||||
const (
|
||||
flagNoBufferGrow Flags = 1 << iota
|
||||
flagDoneParsingHeader
|
||||
flagOOMReached
|
||||
flagConnClose
|
||||
flagNoHTTP11
|
||||
flagMangledBuffer // set when header fields appended to buffer via Add,Set calls
|
||||
flagKVAppended // set after KV appended to buffer outside Read methods.
|
||||
flagReaderEOF
|
||||
// set if [Header.SetStatus] or [Header.SetStatusInt] has been called.
|
||||
FlagStatusSet
|
||||
)
|
||||
|
||||
// HasAny returns true if any of the argument flags are set.
|
||||
func (f Flags) HasAny(checkThese Flags) bool {
|
||||
return f&checkThese != 0
|
||||
}
|
||||
|
||||
// HeaderV1 implements "raw" HTTP header key-value parsing, validation and marshalling.
|
||||
//
|
||||
// It does NOT implement:
|
||||
// - Normalization.
|
||||
// - Cookies (see [Cookie]).
|
||||
// - Special header optimizations.
|
||||
// - Content-Length validation and other special header field value validation.
|
||||
type HeaderV1 struct {
|
||||
hbuf headerv1Buf
|
||||
|
||||
// Request fields.
|
||||
method view
|
||||
requestTarget view
|
||||
proto view
|
||||
|
||||
// Response fields.
|
||||
statusCode view
|
||||
statusText view
|
||||
_ noCopy
|
||||
}
|
||||
|
||||
// Flags returns [Flags] to signal status code has been set, Connection:Close or other useful signals provided by flags.
|
||||
func (h *HeaderV1) Flags() Flags { return h.hbuf.kv.flags }
|
||||
|
||||
// ConfigBufferGrowth configures the memory the header may use. Setting
|
||||
// outlives [HeaderV1.Reset]. Call before parsing/reading.
|
||||
//
|
||||
// enableBufferGrowth enables growing both the header buffer and the header key/value pair slice.
|
||||
func (h *HeaderV1) ConfigBufferGrowth(enableBufferGrowth bool) {
|
||||
h.hbuf.kv.EnableBufferGrowth(enableBufferGrowth)
|
||||
}
|
||||
|
||||
// ParseBytes copies the bytes into buffer and parses the HTTP header. It fails if HTTP header data is incomplete.
|
||||
func (h *HeaderV1) ParseBytes(asResponse bool, b []byte) error {
|
||||
h.Reset(nil, 0)
|
||||
err := h.hbuf.kv.ReadFromBytes(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.parse(asResponse)
|
||||
}
|
||||
|
||||
// Parse parses accumulated data in-place with no copying. One can set HTTP header data buffer with [HeaderV1.Reset].
|
||||
// It fails if HTTP data is incomplete.
|
||||
func (h *HeaderV1) Parse(asResponse bool) error {
|
||||
debuglog("http:parse:reset")
|
||||
h.Reset(h.hbuf.kv.buf, 0)
|
||||
debuglog("http:parse:start")
|
||||
return h.parse(asResponse)
|
||||
}
|
||||
|
||||
// TryParse begins parsing or resumes parsing from a failed previous attempt from any of the Parse* methods.
|
||||
// As long as needMoreData returns true future calls to TryParse may succeed and the header is not done parsing.
|
||||
// Users may call [HeaderV1.ForEach] in-between TryParse calls so as to validate values before header is completely parsed.
|
||||
//
|
||||
// needMoreData := true
|
||||
// var err error
|
||||
// for needMoreData {
|
||||
// _, err = h.ReadFrom(r, 1024)
|
||||
// if err != nil {
|
||||
// break
|
||||
// }
|
||||
// needMoreData, err = h.TryParse(asResponse)
|
||||
// }
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
func (h *HeaderV1) TryParse(asResponse bool) (needMoreData bool, err error) {
|
||||
flags := h.Flags()
|
||||
if flags.HasAny(flagDoneParsingHeader) {
|
||||
return false, errAlreadyParsed
|
||||
} else if flags.HasAny(flagMangledBuffer) {
|
||||
return false, errMangledBuffer
|
||||
}
|
||||
if asResponse && h.statusCode.len == 0 || !asResponse && h.requestTarget.start == 0 {
|
||||
err = h.parseFirstLine(asResponse)
|
||||
if err != nil {
|
||||
return err == ErrNeedMoreData, err
|
||||
}
|
||||
}
|
||||
err = h.parseNextHeaders(flags)
|
||||
return err == ErrNeedMoreData, err
|
||||
}
|
||||
|
||||
// ParsingSuccess returns true if TryParse was successful, that is to say it returned needMoreData==false and err==nil.
|
||||
func (h *HeaderV1) ParsingSuccess() bool {
|
||||
return h.Flags().HasAny(flagDoneParsingHeader)
|
||||
}
|
||||
|
||||
// ReadFromLimited reads at most maxBytesToRead from reader and appends them to underlying buffer.
|
||||
// Used to accumulate HTTP header for later parsing with [HeaderV1.TryParse].
|
||||
// If read is successful (read length>0) and reader returns [io.EOF] then ReadFromLimited will return a nil error.
|
||||
func (h *HeaderV1) ReadFromLimited(r io.Reader, maxBytesToRead int) (int, error) {
|
||||
return h.hbuf.kv.ReadLimited(r, maxBytesToRead)
|
||||
}
|
||||
|
||||
// ReadFromBytes appends argument buffer to underlying buffer.
|
||||
// Used to accumulate HTTP header for later parsing with [HeaderV1.TryParse].
|
||||
func (h *HeaderV1) ReadFromBytes(b []byte) error {
|
||||
return h.hbuf.kv.ReadFromBytes(b)
|
||||
}
|
||||
|
||||
// BufferReceived returns the amoung of bytes read during calls to Read* methods.
|
||||
// Returns 0 if buffer is invalid/mangled.
|
||||
func (h *HeaderV1) BufferReceived() int {
|
||||
if h.Flags().HasAny(flagMangledBuffer | flagOOMReached) {
|
||||
return 0
|
||||
}
|
||||
return len(h.hbuf.kv.BufferRaw())
|
||||
}
|
||||
|
||||
// BufferParsed returns the amount of bytes parsed during a call to Parse* methods.
|
||||
// If the Parse* method completed without error then BufferParsed returns the header's length including the final "\r\n\r\n" text.
|
||||
// BufferParsed returns 0 if the buffer is invalid/mangled or if no header data has been parsed succesfully.
|
||||
func (h *HeaderV1) BufferParsed() int {
|
||||
if h.Flags().HasAny(flagMangledBuffer | flagOOMReached) {
|
||||
return 0
|
||||
}
|
||||
return h.hbuf.off
|
||||
}
|
||||
|
||||
// BufferRaw returns the undeerlying buffer as stored currently in memory.
|
||||
// The length of the returned buffer is the used portion. Capacity of returned slice is [HeaderV1.BufferCapacity].
|
||||
func (h *HeaderV1) BufferRaw() []byte { return h.hbuf.kv.BufferRaw() }
|
||||
|
||||
// BufferUsed returns the raw memory used.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *HeaderV1) BufferUsed() int {
|
||||
return len(h.hbuf.kv.BufferRaw())
|
||||
}
|
||||
|
||||
// BufferFree returns amount of bytes free in underlying buffer.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *HeaderV1) BufferFree() int {
|
||||
return h.hbuf.free()
|
||||
}
|
||||
|
||||
// BufferCapacity returns the total capacity of the underlying buffer.
|
||||
//
|
||||
// BufferUsed + BufferFree == BufferCapacity
|
||||
func (h *HeaderV1) BufferCapacity() int {
|
||||
return cap(h.hbuf.kv.BufferRaw())
|
||||
}
|
||||
|
||||
// ForEach iterates over header key-value field tuples.
|
||||
func (h *HeaderV1) ForEach(cb func(key, value []byte) bool) {
|
||||
h.hbuf.kv.ForEach(cb)
|
||||
}
|
||||
|
||||
// Reset discards all parsed data and sets the buffer data to buf. This method
|
||||
// can be used to avoid copying and growing buffers. Call [HeaderV1.Parse] after setting buffer
|
||||
// data with Reset to parse data in-place.
|
||||
// If buf is nil then the current buffer is reused. There are 3 ways to use Reset:
|
||||
//
|
||||
// h.Reset(prealloc[:0], 16); h.ParseBytes(httpHeader) // Tell header to use a pre-allocated buffer capacity.
|
||||
// h.Reset(httpHeader, 16); h.Parse() // Parse bytes in place with no copying.
|
||||
// h.Reset(nil) // Reuse buffer previously set in a call to Reset.
|
||||
func (h *HeaderV1) Reset(buf []byte, numHeaderCapacity int) {
|
||||
const persistentFlags = flagNoBufferGrow
|
||||
debuglog("http:reset:hbuf")
|
||||
h.hbuf.reset(buf, numHeaderCapacity)
|
||||
if h.Flags().HasAny(flagNoBufferGrow) && h.BufferCapacity() < 32 {
|
||||
panic("small buffer and flagNoBufferGrow set")
|
||||
}
|
||||
*h = HeaderV1{hbuf: h.hbuf}
|
||||
debuglog("http:reset:done")
|
||||
}
|
||||
|
||||
// Body returns the surplus data following headers. It is only valid as long as Parse* or Reset methods are not called.
|
||||
func (h *HeaderV1) Body() ([]byte, error) {
|
||||
debuglog("http:body")
|
||||
flags := h.Flags()
|
||||
if flags.HasAny(flagMangledBuffer) {
|
||||
return nil, errMangledBuffer
|
||||
} else if flags.HasAny(flagDoneParsingHeader) {
|
||||
return h.BufferRaw()[h.hbuf.off:], nil
|
||||
}
|
||||
return nil, errUnparsed
|
||||
}
|
||||
|
||||
// SetBytes is equivalent to [HeaderV1.Set] but with a []byte value. Does not keep reference to value slice.
|
||||
// Calling SetBytes Mangles the buffer.
|
||||
func (h *HeaderV1) SetBytes(key string, value []byte) {
|
||||
h.Set(key, b2s(value))
|
||||
}
|
||||
|
||||
// SetInt is equivalent to [HeaderV1.Set] but with an integer value i.e: Content-Length header key.
|
||||
// base must be in the range 2..36 (as accepted by [strconv.AppendInt]); other bases are dropped.
|
||||
// SetInt formats the value directly into the header buffer without heap allocation.
|
||||
func (h *HeaderV1) SetInt(key string, value int64, base int) {
|
||||
if base < 2 || base > 36 {
|
||||
return // strconv.AppendInt only supports base 2..36.
|
||||
}
|
||||
h.hbuf.kv.SetInt(key, value, base)
|
||||
}
|
||||
|
||||
// Set sets a key-value pair in the HTTP header.
|
||||
// Calling Set mangles the buffer.
|
||||
func (h *HeaderV1) Set(key, value string) (enoughSpace bool) {
|
||||
return h.hbuf.kv.Set(key, value)
|
||||
}
|
||||
|
||||
// Get gets the first exact-match value of a key found in the headers. Use [HeaderV1.ForEach] to find multiple values corresponding to same key.
|
||||
func (h *HeaderV1) Get(key string) []byte {
|
||||
return h.hbuf.kv.Get(key)
|
||||
}
|
||||
|
||||
// GetFold gets the first value whose key matches key under ASCII case-insensitive
|
||||
// comparison, i.e: "content-length" matches "Content-Length".
|
||||
// Use [HeaderV1.Get] for exact match and [HeaderV1.ForEach] to find multiple values
|
||||
// corresponding to same key.
|
||||
func (h *HeaderV1) GetFold(key string) []byte {
|
||||
return h.hbuf.kv.GetFold(key)
|
||||
}
|
||||
|
||||
// NormalizeKeys normalizes all header keys. i.e: CONTENT-type -> Content-Type
|
||||
func (h *HeaderV1) NormalizeKeys() {
|
||||
for i, kv := range h.hbuf.kv.kvs {
|
||||
if kv.isValidHeader() {
|
||||
NormalizeHeaderKey(h.hbuf.kv.AtKey(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ContentLength returns the body length declared by the Content-Length field.
|
||||
// If the field is not present then the returned bool is false. Will return error for invalid or non-integer value.
|
||||
func (h *HeaderV1) ContentLength() (_ int64, present bool, _ error) {
|
||||
value := h.GetFold(headerContentLength)
|
||||
if value == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
value = trimOWS(value)
|
||||
// Unsigned parse of 63 bits rejects a sign and anything past int64's range.
|
||||
n, err := strconv.ParseInt(b2s(value), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return n, true, errBadContentLength // strconv's error allocates and is not comparable.
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
// Add adds a new key-value pair to the HTTP header. Calling Add mangles the buffer.
|
||||
func (h *HeaderV1) Add(key, value string) {
|
||||
h.hbuf.kv.appendPair(key, value)
|
||||
}
|
||||
|
||||
// Method returns HTTP request method.
|
||||
func (h *HeaderV1) Method() []byte {
|
||||
return h.getNonEmptyValue(h.method)
|
||||
}
|
||||
|
||||
// SetMethod sets the request header's method.
|
||||
func (h *HeaderV1) SetMethod(method string) {
|
||||
h.method = h.hbuf.kv.reuseOrAppend(h.method, method)
|
||||
}
|
||||
|
||||
// SetRequestTarget sets request-target (URI) for the first HTTP request line.
|
||||
func (h *HeaderV1) SetRequestTarget(requestTarget string) {
|
||||
h.requestTarget = h.hbuf.kv.reuseOrAppend(h.requestTarget, requestTarget)
|
||||
}
|
||||
|
||||
// RequestTarget returns a view of the request-target (URI) of the first HTTP request line.
|
||||
// Called Request-URI in the obsolete RFC 2616, renamed request-target by RFC 9112.
|
||||
func (h *HeaderV1) RequestTarget() []byte {
|
||||
return h.getNonEmptyValue(h.requestTarget)
|
||||
}
|
||||
|
||||
// RequestPath returns the request-target (URI) up to the query string, i.e: "/search"
|
||||
// for "/search?q=go". Returns the whole target if it contains no query string.
|
||||
func (h *HeaderV1) RequestPath() []byte {
|
||||
target := h.RequestTarget()
|
||||
before, _, ok := bytes.Cut(target, []byte{'?'})
|
||||
if !ok {
|
||||
return target
|
||||
}
|
||||
return before
|
||||
}
|
||||
|
||||
// RequestQuery returns the request-target (URI) query string as it appears on the
|
||||
// wire, percent-encoded and with '+' undecoded, i.e: "q=go" for "/search?q=go".
|
||||
// Returns nil if the target has no query string. Iterate it with [NextQueryPair].
|
||||
func (h *HeaderV1) RequestQuery() []byte {
|
||||
target := h.RequestTarget()
|
||||
_, after, ok := bytes.Cut(target, []byte{'?'})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return after
|
||||
}
|
||||
|
||||
// Protocol returns the request header's HTTP protocol. Usually "HTTP/1.1".
|
||||
func (h *HeaderV1) Protocol() []byte {
|
||||
return h.getNonEmptyValue(h.proto)
|
||||
}
|
||||
|
||||
// SetProtocol sets the request header's protocol. Usually "HTTP/1.1".
|
||||
func (h *HeaderV1) SetProtocol(protocol string) {
|
||||
h.proto = h.hbuf.kv.reuseOrAppend(h.proto, protocol)
|
||||
}
|
||||
|
||||
// Status returns the response header's status code and status text. i.e: "200" "OK".
|
||||
func (h *HeaderV1) Status() (code, statusText []byte) {
|
||||
if h.statusCode.len == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.hbuf.kv.musttoken(h.statusCode), h.hbuf.kv.musttoken(h.statusText)
|
||||
}
|
||||
|
||||
// SetStatus sets the response header's status code and status text. i.e: "200" "OK".
|
||||
func (h *HeaderV1) SetStatus(code, statusText string) {
|
||||
h.hbuf.kv.flags |= FlagStatusSet
|
||||
h.statusCode = h.hbuf.kv.reuseOrAppend(h.statusCode, code)
|
||||
h.statusText = h.hbuf.kv.reuseOrAppend(h.statusText, statusText)
|
||||
}
|
||||
|
||||
// SetStatusInt is identical to [HeaderV1.SetStatus] but performs integer to text conversion for status code.
|
||||
func (h *HeaderV1) SetStatusInt(code int64, statusText string) {
|
||||
h.hbuf.kv.flags |= FlagStatusSet
|
||||
h.statusCode = h.hbuf.kv.reuseOrAppendInt(h.statusCode, code, 10)
|
||||
h.statusText = h.hbuf.kv.reuseOrAppend(h.statusText, statusText)
|
||||
}
|
||||
|
||||
func (h *HeaderV1) getNonEmptyValue(s view) []byte {
|
||||
if s.len == 0 {
|
||||
return nil // If empty then value is invalid, return nil.
|
||||
}
|
||||
return h.hbuf.kv.musttoken(s)
|
||||
}
|
||||
|
||||
// AppendRequest appends the request header representation to the buffer and returns the result.
|
||||
func (h *HeaderV1) AppendRequest(dst []byte) ([]byte, error) {
|
||||
proto := h.Protocol()
|
||||
if h.hbuf.kv.flags.HasAny(flagOOMReached) {
|
||||
return dst, ErrBufferExhausted
|
||||
} else if h.requestTarget.len == 0 || h.method.len == 0 {
|
||||
return dst, errNeedMethodURI
|
||||
} else if len(proto) == 0 {
|
||||
return dst, errNoProto
|
||||
}
|
||||
|
||||
method := h.Method()
|
||||
if len(method) == 0 {
|
||||
dst = append(dst, methodGet...)
|
||||
} else {
|
||||
dst = append(dst, method...)
|
||||
}
|
||||
uri := h.RequestTarget()
|
||||
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, uri...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, proto...)
|
||||
dst = append(dst, strCRLF...)
|
||||
|
||||
dst = h.AppendHeaders(dst)
|
||||
|
||||
return append(dst, strCRLF...), nil
|
||||
}
|
||||
|
||||
// AppendResponse appends the response header representation to the buffer and returns the result.
|
||||
func (h *HeaderV1) AppendResponse(dst []byte) ([]byte, error) {
|
||||
dst, err := h.AppendResponseNoHeaders(dst)
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
dst = h.AppendHeaders(dst)
|
||||
return append(dst, strCRLF...), nil
|
||||
}
|
||||
|
||||
// AppendResponseNoHeaders appends the first line of the response containing protocol and status code/text: i.e: "HTTP/1.1 200 OK\r\n"
|
||||
func (h *HeaderV1) AppendResponseNoHeaders(dst []byte) ([]byte, error) {
|
||||
proto := h.Protocol()
|
||||
if h.hbuf.kv.flags.HasAny(flagOOMReached) {
|
||||
return dst, ErrBufferExhausted
|
||||
} else if h.statusCode.len == 0 || h.statusText.len == 0 {
|
||||
return dst, errBadStatusCodeTxt
|
||||
} else if len(proto) == 0 {
|
||||
return dst, errNoProto
|
||||
}
|
||||
code, text := h.Status()
|
||||
|
||||
dst = append(dst, proto...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, code...)
|
||||
dst = append(dst, ' ')
|
||||
dst = append(dst, text...)
|
||||
dst = append(dst, strCRLF...)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// AppendHeaders appends headers to buffer. Use AppendRequest and AppendResponse over this.
|
||||
// Does not append extra \r\n to end. Appends nothing if contains no headers.
|
||||
func (h *HeaderV1) AppendHeaders(dst []byte) []byte {
|
||||
for i, kv := range h.hbuf.kv.kvs {
|
||||
if kv.isValidHeader() {
|
||||
k, v := h.hbuf.kv.At(i)
|
||||
dst = appendHeaderLine(dst, b2s(k), b2s(v))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// String returns the header's wire representation, as a request if it has a
|
||||
// request line and as a response otherwise. Returns the error text if neither
|
||||
// can be built. Allocates, so it is meant for debugging and logging only.
|
||||
func (h *HeaderV1) String() string {
|
||||
buf, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
buf, err = h.AppendResponse(nil)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
return b2s(buf)
|
||||
}
|
||||
|
||||
func appendHeaderLine(dst []byte, key, value string) []byte {
|
||||
dst = append(dst, key...)
|
||||
dst = append(dst, ':', ' ')
|
||||
dst = append(dst, value...)
|
||||
return append(dst, strCRLF...)
|
||||
}
|
||||
|
||||
// Embed this type into a struct, which mustn't be copied,
|
||||
// so `go vet` gives a warning if this struct is copied.
|
||||
//
|
||||
// See https://github.com/golang/go/issues/8005#issuecomment-190753527 for details.
|
||||
// and also: https://stackoverflow.com/questions/52494458/nocopy-minimal-example
|
||||
type noCopy struct{}
|
||||
|
||||
func (*noCopy) Lock() {}
|
||||
func (*noCopy) Unlock() {}
|
||||
|
||||
// NormalizeKey normalizes a HTTP header key in-place. Returns true if buffer modified.
|
||||
// Examples of normalization:
|
||||
// - CONTENT -> Content
|
||||
// - content-length -> Content-Length
|
||||
// - cOnTeNt-LenGtH -> Content-Length
|
||||
func NormalizeHeaderKey(b []byte) (modified bool) {
|
||||
if len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
const asciiCapDiff = 'a' - 'A'
|
||||
for i := -1; i < len(b); i++ {
|
||||
nextToUpper := i == -1 || (b[i] == '-' && i < len(b)-1)
|
||||
if nextToUpper {
|
||||
i++
|
||||
isLower := b[i] >= 'a' && b[i] <= 'z'
|
||||
if isLower {
|
||||
modified = true
|
||||
b[i] -= asciiCapDiff
|
||||
}
|
||||
} else {
|
||||
isUpper := b[i] >= 'A' && b[i] <= 'Z'
|
||||
if isUpper {
|
||||
modified = true
|
||||
b[i] += asciiCapDiff
|
||||
}
|
||||
}
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
// CopyNormalizedHeaderValue copies the header value in the value buffer to dst.
|
||||
// The result may be shrunk. The target and source buffers can only alias if the
|
||||
// destination buffer 0 address is equal to value's 0 address.
|
||||
// Header value normalization implies the replacement of \r\n\t with a single space.
|
||||
func CopyNormalizedHeaderValue(dst []byte, value []byte) (n int, modified bool) {
|
||||
if len(dst) < len(value) {
|
||||
panic("httpraw.CopyNormalizedHeaderValue: dst buffer shorter than length")
|
||||
}
|
||||
write := 0
|
||||
read := 0
|
||||
for {
|
||||
rmStart := bytes.IndexByte(value[read:], '\n')
|
||||
if rmStart < 0 {
|
||||
write += copy(dst[write:], value[read:])
|
||||
break
|
||||
}
|
||||
omit := 1
|
||||
rmStart += read
|
||||
if rmStart+1 < len(value) && value[rmStart+1] == '\t' {
|
||||
omit++
|
||||
}
|
||||
if rmStart > 0 && value[rmStart-1] == '\r' {
|
||||
rmStart--
|
||||
omit++
|
||||
}
|
||||
modified = true
|
||||
n := copy(dst[write:], value[read:rmStart])
|
||||
dst[write+n] = ' '
|
||||
read += omit + n
|
||||
write += n + 1
|
||||
}
|
||||
return write, modified
|
||||
}
|
||||
|
||||
// CopyDecodedPercentURL decodes percent-escapes in value into dst and returns bytes written.
|
||||
// n < len(value) implies percent-escapes were decoded; the converse does not hold since
|
||||
// '+' substitution preserves length. If plusAsSpace is set '+' decodes to ' ',
|
||||
// which is correct for query and form-encoded data but NOT for path segments.
|
||||
// On malformed escape returns n bytes written before the fault and a non-nil error.
|
||||
// dst and value may only alias if &dst[0] == &value[0].
|
||||
func CopyDecodedPercentURL(dst, value []byte, plusAsSpace bool) (n int, err error) {
|
||||
if len(dst) < len(value) {
|
||||
panic("httpraw.CopyDecodedPercentURL: dst buffer shorter than value")
|
||||
}
|
||||
read := 0
|
||||
for {
|
||||
escape := bytes.IndexByte(value[read:], '%')
|
||||
if escape < 0 {
|
||||
n += copyPlusDecoded(dst[n:], value[read:], plusAsSpace)
|
||||
return n, nil
|
||||
}
|
||||
escape += read
|
||||
n += copyPlusDecoded(dst[n:], value[read:escape], plusAsSpace)
|
||||
if escape+2 >= len(value) {
|
||||
return n, errBadPercentEncode // Truncated escape at end of value.
|
||||
}
|
||||
hi, okhi := unhexdigit(value[escape+1])
|
||||
lo, oklo := unhexdigit(value[escape+2])
|
||||
if !okhi || !oklo {
|
||||
return n, errBadPercentEncode
|
||||
}
|
||||
// Write index n is always <= escape since decoding shrinks 3 bytes to 1,
|
||||
// so writing here never clobbers an unread byte when dst aliases value.
|
||||
dst[n] = hi<<4 | lo
|
||||
n++
|
||||
read = escape + 3
|
||||
}
|
||||
}
|
||||
|
||||
// EqualDecodedPercentURL reports whether value, once decoded, equals want. It
|
||||
// decodes as it compares so it needs no scratch buffer, and reports false on a
|
||||
// malformed escape just as [CopyDecodedPercentURL] errors on one.
|
||||
// plusAsSpace decodes '+' to ' ', correct for query and form-encoded data but
|
||||
// NOT for path segments.
|
||||
func EqualDecodedPercentURL(value []byte, want string, plusAsSpace bool) bool {
|
||||
w := 0
|
||||
for i := 0; i < len(value); {
|
||||
var c byte
|
||||
switch {
|
||||
case value[i] == '%':
|
||||
if i+2 >= len(value) {
|
||||
return false // Truncated escape at end of value.
|
||||
}
|
||||
hi, okhi := unhexdigit(value[i+1])
|
||||
lo, oklo := unhexdigit(value[i+2])
|
||||
if !okhi || !oklo {
|
||||
return false
|
||||
}
|
||||
c = hi<<4 | lo
|
||||
i += 3
|
||||
case plusAsSpace && value[i] == '+':
|
||||
c = ' '
|
||||
i++
|
||||
default:
|
||||
c = value[i]
|
||||
i++
|
||||
}
|
||||
if w >= len(want) || want[w] != c {
|
||||
return false
|
||||
}
|
||||
w++
|
||||
}
|
||||
return w == len(want)
|
||||
}
|
||||
|
||||
// copyPlusDecoded copies src to dst replacing '+' with ' ' if plusAsSpace set.
|
||||
func copyPlusDecoded(dst, src []byte, plusAsSpace bool) int {
|
||||
n := copy(dst, src)
|
||||
if plusAsSpace {
|
||||
for i := range n {
|
||||
if dst[i] == '+' {
|
||||
dst[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func unhexdigit(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, true
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultKVCap is the key/value table size tests hand to Reset. A Reset with 0
|
||||
// preserves whatever capacity the value already had, which is what production
|
||||
// code wants on reuse but leaves a fresh value unable to hold a single pair when
|
||||
// growth is disabled, see [Form.Reset].
|
||||
const defaultKVCap = 16
|
||||
|
||||
func TestHeaderParseRequest(t *testing.T) {
|
||||
const (
|
||||
wantMethod = "GET"
|
||||
wantURI = "/data/set"
|
||||
wantMessage = "hello world!"
|
||||
asRequest = false
|
||||
asResponse = true
|
||||
)
|
||||
req, err := http.NewRequest(wantMethod, wantURI, strings.NewReader(wantMessage))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantCookie http.Cookie
|
||||
wantCookie.SameSite = http.SameSiteLaxMode
|
||||
wantCookie.MaxAge = 360000
|
||||
wantCookie.Name = "key"
|
||||
wantCookie.Value = "value"
|
||||
wantCookie.Expires = time.Now().Add(time.Hour)
|
||||
wantCookie.Domain = "DOM"
|
||||
wantCookie.HttpOnly = true
|
||||
wantCookie.Secure = true
|
||||
wantCookie.Path = "/abc"
|
||||
req.Header.Set("Cookie", wantCookie.String())
|
||||
t.Log("valid cookie:", wantCookie.Valid() == nil, wantCookie.String())
|
||||
|
||||
var buf bytes.Buffer
|
||||
req.Write(&buf)
|
||||
var hdr HeaderV1
|
||||
msg := buf.Bytes()
|
||||
|
||||
start := time.Now()
|
||||
err = hdr.ParseBytes(asRequest, msg)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%s\nparsed in %s\n\n", msg, elapsed.String())
|
||||
if string(hdr.Method()) != wantMethod {
|
||||
t.Errorf("want method %s, got %q", wantMethod, hdr.Method())
|
||||
}
|
||||
if !bytes.Equal(hdr.RequestTarget(), []byte(wantURI)) {
|
||||
t.Errorf("want request URI %q, got %q", wantURI, hdr.RequestTarget())
|
||||
}
|
||||
contentLength, _ := strconv.Atoi(string(hdr.Get("Content-Length")))
|
||||
if contentLength != len(wantMessage) {
|
||||
t.Errorf("want Content-Length %d, got %d", len(wantMessage), contentLength)
|
||||
}
|
||||
var c Cookie
|
||||
cookie := hdr.Get("Cookie")
|
||||
c.Reset(cookie, defaultKVCap)
|
||||
err = c.Parse()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
key := string(c.Name())
|
||||
if key != wantCookie.Name {
|
||||
t.Errorf("want cookie key %q, got %q", wantCookie.Name, key)
|
||||
}
|
||||
value := string(c.Value())
|
||||
if value != wantCookie.Value {
|
||||
t.Errorf("want cookie key %q, got %q", wantCookie.Value, value)
|
||||
}
|
||||
domain := string(c.Get("Domain"))
|
||||
if domain != wantCookie.Domain {
|
||||
t.Errorf("want domain %q, got %q", wantCookie.Domain, domain)
|
||||
}
|
||||
httpOnly := c.HasKeyOrSingleValue("HttpOnly")
|
||||
if httpOnly != wantCookie.HttpOnly {
|
||||
t.Errorf("want cookie HttpOnly %v, got %v", wantCookie.HttpOnly, httpOnly)
|
||||
}
|
||||
secure := c.HasKeyOrSingleValue("Secure")
|
||||
if secure != wantCookie.Secure {
|
||||
t.Errorf("want cookie HttpOnly %v, got %v", wantCookie.Secure, secure)
|
||||
}
|
||||
samesite := string(c.Get("SameSite"))
|
||||
if samesite != strSameSite(wantCookie.SameSite) {
|
||||
t.Errorf("want cookie SameSite %v, got %v", strSameSite(wantCookie.SameSite), samesite)
|
||||
}
|
||||
body, err := hdr.Body()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if string(body) != wantMessage {
|
||||
t.Errorf("want body message %q, got %q", wantMessage, body)
|
||||
}
|
||||
cookieStr := string(c.AppendKeyValues(nil))
|
||||
if wantCookie.String() != cookieStr {
|
||||
t.Errorf("want full cookie representation\n%qgot:\n%q", wantCookie.String(), cookieStr)
|
||||
}
|
||||
data, _ := hdr.AppendRequest(nil)
|
||||
fmt.Printf("%s", data)
|
||||
}
|
||||
|
||||
func BenchmarkParseBytes(b *testing.B) {
|
||||
b.StopTimer()
|
||||
const (
|
||||
wantMethod = "GET"
|
||||
wantURI = "/data/set"
|
||||
wantMessage = "hello world!"
|
||||
asRequest = false
|
||||
)
|
||||
req, _ := http.NewRequest(wantMethod, wantURI, strings.NewReader(wantMessage))
|
||||
var rawBuf bytes.Buffer
|
||||
req.Write(&rawBuf)
|
||||
data := rawBuf.Bytes()
|
||||
|
||||
// hdr is declared outside the loop so that ParseBytes can reuse the
|
||||
// backing arrays on Reset (headers slice and data buffer) without
|
||||
// allocating on every iteration. Declaring it inside the loop causes
|
||||
// two allocs per iteration: one for the headers slice (make in reset)
|
||||
// and one for the data buffer (append in readFromBytes).
|
||||
var hdr HeaderV1
|
||||
b.StartTimer()
|
||||
|
||||
for b.Loop() {
|
||||
err := hdr.ParseBytes(asRequest, data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, err = hdr.Body()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strSameSite(mode http.SameSite) string {
|
||||
switch mode {
|
||||
case http.SameSiteLaxMode:
|
||||
return "Lax"
|
||||
case http.SameSiteDefaultMode:
|
||||
return ""
|
||||
case http.SameSiteStrictMode:
|
||||
return "Strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "None"
|
||||
default:
|
||||
panic("invalid same site")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderRequestPath(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
uri string
|
||||
want string
|
||||
}{
|
||||
{uri: "/", want: "/"},
|
||||
{uri: "/search?q=go", want: "/search"},
|
||||
{uri: "/search?", want: "/search"},
|
||||
{uri: "/a/b/c?x=1&y=2", want: "/a/b/c"},
|
||||
{uri: "/?q=go", want: "/"},
|
||||
} {
|
||||
var h HeaderV1
|
||||
err := h.ParseBytes(false, []byte("GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(h.RequestPath()); got != test.want {
|
||||
t.Errorf("uri %q: want path %q, got %q", test.uri, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderContentLength(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
field string // Extra header lines, empty for an absent field.
|
||||
want int64
|
||||
wantErr error
|
||||
}{
|
||||
{field: "Content-Length: 0", want: 0},
|
||||
{field: "Content-Length: 12", want: 12},
|
||||
{field: "Content-Length: 12 ", want: 12}, // OWS around the value, RFC 9110 5.6.3.
|
||||
{field: "Content-Length: 9223372036854775807", want: 9223372036854775807},
|
||||
{field: ""}, // Absent field is no body and no error, RFC 9112 6.3.
|
||||
{field: "Content-Length:", wantErr: errBadContentLength}, // Present but empty.
|
||||
{field: "Content-Length: -1", wantErr: errBadContentLength}, // Digits only, RFC 9112 6.2.
|
||||
{field: "Content-Length: 1 2", wantErr: errBadContentLength}, // Not a list.
|
||||
{field: "Content-Length: 9223372036854775808", wantErr: errBadContentLength},
|
||||
} {
|
||||
var h HeaderV1
|
||||
raw := "POST / HTTP/1.1\r\nHost: h\r\n"
|
||||
if test.field != "" {
|
||||
raw += test.field + "\r\n"
|
||||
}
|
||||
if err := h.ParseBytes(false, []byte(raw+"\r\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, present, err := h.ContentLength()
|
||||
if err != test.wantErr {
|
||||
t.Errorf("%q: want error %v, got %v", test.field, test.wantErr, err)
|
||||
} else if err == nil && got != test.want {
|
||||
t.Errorf("%q: want %d, got %d", test.field, test.want, got)
|
||||
}
|
||||
key, _, _ := strings.Cut(test.field, ":")
|
||||
if strings.EqualFold(key, headerContentLength) != present {
|
||||
t.Error("unexpected 'present'", test.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextQueryPair(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
uri string
|
||||
want string // "key=value" pairs joined by '|'; nil value shown as "key".
|
||||
}{
|
||||
{uri: "/", want: ""},
|
||||
{uri: "/x?", want: ""},
|
||||
{uri: "/x?q=go", want: "q=go"},
|
||||
{uri: "/x?q=go&n=1", want: "q=go|n=1"},
|
||||
{uri: "/x?debug&q=go", want: "debug|q=go"}, // No '=' yields a nil value.
|
||||
{uri: "/x?q=", want: "q="}, // Empty but present value.
|
||||
{uri: "/x?&&q=go&", want: "q=go"}, // Empty sequences skipped.
|
||||
{uri: "/x?=v", want: "=v"}, // Empty name is kept.
|
||||
{uri: "/x?a=1&a=2", want: "a=1|a=2"}, // Duplicates all yielded.
|
||||
{uri: "/x?a%20b=c%20d", want: "a%20b=c%20d"}, // Raw, undecoded.
|
||||
{uri: "/x?a=b=c", want: "a=b=c"}, // Only first '=' splits.
|
||||
} {
|
||||
var h HeaderV1
|
||||
err := h.ParseBytes(false, []byte("GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []byte
|
||||
rawkey, rawval, rest := NextQueryPair(h.RequestQuery())
|
||||
for rawkey != nil {
|
||||
if len(got) > 0 {
|
||||
got = append(got, '|')
|
||||
}
|
||||
got = append(got, rawkey...)
|
||||
if rawval != nil {
|
||||
got = append(got, '=')
|
||||
got = append(got, rawval...)
|
||||
}
|
||||
rawkey, rawval, rest = NextQueryPair(rest)
|
||||
}
|
||||
if string(got) != test.want {
|
||||
t.Errorf("uri %q: want %q, got %q", test.uri, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil key ends iteration, and stopping early is just not looping again.
|
||||
func TestNextQueryPairEnd(t *testing.T) {
|
||||
rawkey, rawval, rest := NextQueryPair([]byte("a=1&b=2"))
|
||||
if string(rawkey) != "a" || string(rawval) != "1" || string(rest) != "b=2" {
|
||||
t.Fatalf("want a=1 with rest b=2, got %q=%q rest %q", rawkey, rawval, rest)
|
||||
}
|
||||
rawkey, _, rest = NextQueryPair(rest)
|
||||
if string(rawkey) != "b" || len(rest) != 0 {
|
||||
t.Fatalf("want b with empty rest, got %q rest %q", rawkey, rest)
|
||||
}
|
||||
if rawkey, _, _ = NextQueryPair(rest); rawkey != nil {
|
||||
t.Fatalf("want nil key at end of query, got %q", rawkey)
|
||||
}
|
||||
// Trailing separators yield no pair rather than an empty one.
|
||||
if rawkey, _, _ = NextQueryPair([]byte("&&")); rawkey != nil {
|
||||
t.Fatalf("want nil key for empty sequences, got %q", rawkey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderNormalizeKey(t *testing.T) {
|
||||
var tests = []struct {
|
||||
key string
|
||||
wantnorm string
|
||||
}{
|
||||
{key: "", wantnorm: ""},
|
||||
{key: "a-a-a", wantnorm: "A-A-A"},
|
||||
{key: "a-a-a-", wantnorm: "A-A-A-"},
|
||||
{key: "-", wantnorm: "-"},
|
||||
{key: "CONTENT", wantnorm: "Content"},
|
||||
{key: "cONTENT", wantnorm: "Content"},
|
||||
{key: "Content-Length", wantnorm: "Content-Length"},
|
||||
{key: "Content-length", wantnorm: "Content-Length"},
|
||||
{key: "content-length", wantnorm: "Content-Length"},
|
||||
{key: "conTent-lENgth", wantnorm: "Content-Length"},
|
||||
{key: "conTent-lENgth-", wantnorm: "Content-Length-"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
gotKey := []byte(test.key)
|
||||
modified := NormalizeHeaderKey(gotKey)
|
||||
if string(gotKey) != test.wantnorm {
|
||||
t.Errorf("mismatch want %q got %q", test.wantnorm, gotKey)
|
||||
}
|
||||
wantMod := string(gotKey) != test.key
|
||||
if wantMod != modified {
|
||||
t.Errorf("mismatch want mod=%v, got mod=%v", wantMod, modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyNormalizedHeaderValue(t *testing.T) {
|
||||
var tests = []struct {
|
||||
value string
|
||||
wantnorm string
|
||||
}{
|
||||
{value: "abc\r\n\tdef\r\n\tghi", wantnorm: "abc def ghi"},
|
||||
{value: "abc\r\n\tabc", wantnorm: "abc abc"},
|
||||
{value: "abc\n\tdef\n\tghi", wantnorm: "abc def ghi"},
|
||||
{value: "abc\n\tdef\n\tghi\n\t", wantnorm: "abc def ghi "},
|
||||
{value: "abc\n\tdef\n\tghi\r\n\t", wantnorm: "abc def ghi "},
|
||||
{value: "", wantnorm: ""},
|
||||
{value: "abc", wantnorm: "abc"},
|
||||
}
|
||||
dst := make([]byte, 256)
|
||||
for _, test := range tests {
|
||||
value := []byte(test.value)
|
||||
n, modified := CopyNormalizedHeaderValue(dst[:len(value)], value)
|
||||
got := dst[:n]
|
||||
if string(got) != test.wantnorm {
|
||||
t.Errorf("mismatch want %q got %q", test.wantnorm, got)
|
||||
}
|
||||
wantMod := string(got) != test.value
|
||||
if wantMod != modified {
|
||||
t.Errorf("mismatch want mod=%v, got mod=%v", wantMod, modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDecodedPercentURL(t *testing.T) {
|
||||
var tests = []struct {
|
||||
value string
|
||||
plusAsSpace bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{value: "", want: ""},
|
||||
{value: "/plain/path", want: "/plain/path"},
|
||||
{value: "/a%20b", want: "/a b"},
|
||||
{value: "%41%42%43", want: "ABC"},
|
||||
{value: "%2f%2F", want: "//"}, // lower and upper case hex digits.
|
||||
{value: "%25", want: "%"}, // escaped percent must not re-trigger decoding.
|
||||
{value: "%2525", want: "%25"}, // decoded output is not re-scanned.
|
||||
{value: "a+b", want: "a+b"}, // plus is literal in path segments.
|
||||
{value: "a+b", plusAsSpace: true, want: "a b"},
|
||||
{value: "%20+%20", plusAsSpace: true, want: " "},
|
||||
{value: "/x?q=%E2%82%AC", want: "/x?q=\xe2\x82\xac"}, // multi-byte UTF-8 sequence.
|
||||
// Malformed escapes must error, never pass through silently.
|
||||
{value: "%zz", want: "", wantErr: true},
|
||||
{value: "ok%4", want: "ok", wantErr: true}, // truncated escape at end.
|
||||
{value: "ok%", want: "ok", wantErr: true}, // bare percent at end.
|
||||
{value: "a%2gb", want: "a", wantErr: true}, // second digit not hex.
|
||||
{value: "a%g2b", want: "a", wantErr: true}, // first digit not hex.
|
||||
}
|
||||
dst := make([]byte, 256)
|
||||
for _, test := range tests {
|
||||
value := []byte(test.value)
|
||||
n, err := CopyDecodedPercentURL(dst[:len(value)], value, test.plusAsSpace)
|
||||
if test.wantErr && err == nil {
|
||||
t.Errorf("%q: want error, got nil", test.value)
|
||||
} else if !test.wantErr && err != nil {
|
||||
t.Errorf("%q: unexpected error %s", test.value, err)
|
||||
}
|
||||
if got := string(dst[:n]); got != test.want {
|
||||
t.Errorf("%q: want %q got %q", test.value, test.want, got)
|
||||
}
|
||||
// n<len(value) implies percent-escapes were decoded. The converse does not
|
||||
// hold: '+'->' ' substitution preserves length.
|
||||
if !test.wantErr && n < len(test.value) && test.want == test.value {
|
||||
t.Errorf("%q: n=%d signals decoding but value unchanged", test.value, n)
|
||||
}
|
||||
if !test.wantErr && !test.plusAsSpace && test.want != test.value && n >= len(test.value) {
|
||||
t.Errorf("%q: n=%d does not signal decoding of %q", test.value, n, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In-place decoding (dst aliasing value at offset 0) must yield the same result.
|
||||
func TestCopyDecodedPercentURLInPlace(t *testing.T) {
|
||||
const value, want = "/a%20b%2Fc%25", "/a b/c%"
|
||||
buf := []byte(value)
|
||||
n, err := CopyDecodedPercentURL(buf, buf, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(buf[:n]); got != want {
|
||||
t.Fatalf("want %q got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderSetOverwrite(t *testing.T) {
|
||||
var h HeaderV1
|
||||
h.Reset(nil, defaultKVCap)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestTarget("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
h.Set("Host", "first.example.com")
|
||||
h.Set("Host", "second.example.com")
|
||||
|
||||
got := string(h.Get("Host"))
|
||||
if got != "second.example.com" {
|
||||
t.Errorf("want Host %q, got %q", "second.example.com", got)
|
||||
}
|
||||
req, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(string(req), "Host:"); n != 1 {
|
||||
t.Errorf("want 1 Host field in request, got %d:\n%s", n, req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderSetBytesEmptyValue(t *testing.T) {
|
||||
var h HeaderV1
|
||||
h.Reset(nil, defaultKVCap)
|
||||
h.SetBytes("X-Empty", nil)
|
||||
if got := h.Get("X-Empty"); len(got) != 0 {
|
||||
t.Errorf("want empty value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// buffer/offset past uint16 range silently corrupts or panics.
|
||||
// A header block > 64KiB pushes a field's offset past 65535. tokint truncation
|
||||
// makes Get return the wrong window (silent corruption) or panic on slice bounds.
|
||||
func TestHeader_LargeBufferOverflow(t *testing.T) {
|
||||
const wantVal = "the-canary-value"
|
||||
// Pad with a big header so the canary field lands past offset 65535.
|
||||
pad := strings.Repeat("x", 70000)
|
||||
raw := "GET / HTTP/1.1\r\n" +
|
||||
"X-Pad: " + pad + "\r\n" +
|
||||
"X-Canary: " + wantVal + "\r\n" +
|
||||
"\r\n"
|
||||
|
||||
var h HeaderV1
|
||||
err := h.ParseBytes(false, []byte(raw))
|
||||
if err != nil {
|
||||
// Clean rejection of the oversized header is the intended behavior:
|
||||
// no panic, no silent corruption.
|
||||
return
|
||||
}
|
||||
// If it did parse, the value must be correct (never a truncated-offset window).
|
||||
got := string(h.Get("X-Canary"))
|
||||
if got != wantVal {
|
||||
t.Fatalf("overflow corruption: want X-Canary %q, got %q", wantVal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// a complete but malformed header line with no colon must be a hard error,
|
||||
// not ErrNeedMoreData (which makes a streaming parser wait forever).
|
||||
func TestHeader_ColonlessLineIsHardError(t *testing.T) {
|
||||
raw := "GET / HTTP/1.1\r\nBadHeaderNoColon\r\n\r\n"
|
||||
var h HeaderV1
|
||||
err := h.ParseBytes(false, []byte(raw))
|
||||
if err == nil {
|
||||
t.Fatal("want error on colonless header line, got nil")
|
||||
}
|
||||
if err == ErrNeedMoreData {
|
||||
t.Fatalf("colonless line reported as ErrNeedMoreData (parser would hang); want a hard error like errInvalidName")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for a TCP split landing BEFORE the colon (no newline yet) must
|
||||
// stay "need more data" and parse once the rest arrives. This is the case the
|
||||
// Colonless fix must NOT turn into a hard error.
|
||||
func TestHeader_SplitBeforeColonStillParses(t *testing.T) {
|
||||
const part1 = "GET / HTTP/1.1\r\nHost" // split mid-key, before colon+newline
|
||||
const part2 = ": example.com\r\n\r\n"
|
||||
|
||||
var h HeaderV1
|
||||
h.Reset(nil, defaultKVCap)
|
||||
if err := h.ReadFromBytes([]byte(part1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needMore, err := h.TryParse(false)
|
||||
if err != nil && err != ErrNeedMoreData {
|
||||
t.Fatalf("split before colon: want ErrNeedMoreData/nil, got %v", err)
|
||||
}
|
||||
if !needMore {
|
||||
t.Fatal("want needMoreData=true after partial input")
|
||||
}
|
||||
|
||||
if err := h.ReadFromBytes([]byte(part2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needMore, err = h.TryParse(false)
|
||||
if err != nil {
|
||||
t.Fatalf("after full input: %v", err)
|
||||
}
|
||||
if needMore {
|
||||
t.Fatal("want needMoreData=false after full input")
|
||||
}
|
||||
if got := string(h.Get("Host")); got != "example.com" {
|
||||
t.Fatalf("want Host %q, got %q", "example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
// appendHeader must reserve the +1 byte that mustAppendSlice consumes on an
|
||||
// empty buffer. Force cap == len(key)+len(value) to defeat allocator rounding.
|
||||
func TestHeader_AppendHeaderExactCapNoPanic(t *testing.T) {
|
||||
const key, value = "K", "V"
|
||||
buf := make([]byte, 0, len(key)+len(value)) // exact cap, no slack.
|
||||
var h HeaderV1
|
||||
h.Reset(buf, defaultKVCap)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("appendHeader panicked on exact-cap buffer: %v", r)
|
||||
}
|
||||
}()
|
||||
h.Add(key, value)
|
||||
if got := string(h.Get(key)); got != value {
|
||||
t.Fatalf("want %q, got %q", value, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Set on a full buffer with growth disabled must drop gracefully (flag OOM),
|
||||
// never panic. Panicking is unacceptable for this package.
|
||||
func TestHeader_AddFullBufferNoPanic(t *testing.T) {
|
||||
buf := make([]byte, 0, 40) // Small cap; enough for Reset (len 0) but not the field below.
|
||||
var h HeaderV1
|
||||
h.Reset(buf, defaultKVCap)
|
||||
h.ConfigBufferGrowth(false)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestTarget("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Add on full no-grow buffer panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
h.Add("X-Very-Long-Header-Key", "a-value-that-cannot-possibly-fit-in-the-buffer")
|
||||
|
||||
// Graceful drop: request marshalling reports OOM instead of emitting bad data.
|
||||
if _, err := h.AppendRequest(nil); err == nil {
|
||||
t.Fatal("want OOM error after dropped Add, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeader_SetInt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value int64
|
||||
base int
|
||||
want string
|
||||
}{
|
||||
{"decimal", 1234, 10, "1234"},
|
||||
{"zero", 0, 10, "0"},
|
||||
{"negative", -42, 10, "-42"},
|
||||
{"maxint64", 9223372036854775807, 10, "9223372036854775807"},
|
||||
{"minint64", -9223372036854775808, 10, "-9223372036854775808"},
|
||||
{"hex", 255, 16, "ff"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var h HeaderV1
|
||||
h.Reset(nil, defaultKVCap)
|
||||
h.SetInt("Content-Length", tc.value, tc.base)
|
||||
if got := string(h.Get("Content-Length")); got != tc.want {
|
||||
t.Fatalf("want %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetInt on an existing key must reuse the slot in place (single field, latest value).
|
||||
func TestHeader_SetIntOverwrite(t *testing.T) {
|
||||
var h HeaderV1
|
||||
h.Reset(nil, defaultKVCap)
|
||||
h.SetMethod("GET")
|
||||
h.SetRequestTarget("/")
|
||||
h.SetProtocol("HTTP/1.1")
|
||||
|
||||
h.SetInt("Content-Length", 100, 10)
|
||||
h.SetInt("Content-Length", 5, 10) // shorter, must fit in old slot.
|
||||
|
||||
if got := string(h.Get("Content-Length")); got != "5" {
|
||||
t.Fatalf("want Content-Length %q, got %q", "5", got)
|
||||
}
|
||||
req, err := h.AppendRequest(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(string(req), "Content-Length:"); n != 1 {
|
||||
t.Errorf("want 1 Content-Length field, got %d:\n%s", n, req)
|
||||
}
|
||||
}
|
||||
|
||||
// SetInt must not heap-allocate: it must format directly into the header buffer.
|
||||
func TestHeader_SetIntNoAlloc(t *testing.T) {
|
||||
buf := make([]byte, 0, 256)
|
||||
var h HeaderV1
|
||||
h.Reset(buf, defaultKVCap)
|
||||
h.ConfigBufferGrowth(false)
|
||||
h.Add("Content-Length", "0000000000000000000000") // pre-size a reusable slot.
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
h.SetInt("Content-Length", 1234567890, 10)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("SetInt allocated %v times, want 0", allocs)
|
||||
}
|
||||
if got := string(h.Get("Content-Length")); got != "1234567890" {
|
||||
t.Fatalf("want %q, got %q", "1234567890", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser sends upwards of twenty header fields and an API client with a few
|
||||
// custom fields is not far behind. The field table must be sized from the
|
||||
// buffer the caller handed over, not fixed at a count that a real request
|
||||
// exceeds.
|
||||
func TestHeader_FieldTableSizedFromBuffer(t *testing.T) {
|
||||
const wantVal = "the-canary-value"
|
||||
var raw strings.Builder
|
||||
raw.WriteString("GET / HTTP/1.1\r\nHost: lneto.test\r\n")
|
||||
for i := range 40 {
|
||||
raw.WriteString("X-Field-")
|
||||
raw.WriteString(strconv.Itoa(i))
|
||||
raw.WriteString(": value-of-a-realistic-length-here\r\n")
|
||||
}
|
||||
raw.WriteString("X-Canary: " + wantVal + "\r\n\r\n")
|
||||
|
||||
var h HeaderV1
|
||||
h.Reset(make([]byte, 0, 8192), defaultKVCap) // Room for the block with plenty to spare.
|
||||
err := h.ParseBytes(false, []byte(raw.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing a 42 field request into an 8kB buffer: %s", err)
|
||||
}
|
||||
if got := string(h.Get("X-Canary")); got != wantVal {
|
||||
t.Fatalf("want X-Canary %q, got %q", wantVal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A buffer too small for the fields it is handed must be refused with an error
|
||||
// the caller can act on, so a server answers 431 instead of dropping the peer.
|
||||
func TestHeader_FieldTableFullIsReported(t *testing.T) {
|
||||
var raw strings.Builder
|
||||
raw.WriteString("GET / HTTP/1.1\r\n")
|
||||
for i := range 64 {
|
||||
// As short as a field gets.
|
||||
raw.WriteString("H")
|
||||
raw.WriteString(strconv.Itoa(i))
|
||||
raw.WriteString(":v\r\n")
|
||||
}
|
||||
raw.WriteString("\r\n")
|
||||
var h HeaderV1
|
||||
h.Reset(make([]byte, 0, 512), defaultKVCap)
|
||||
h.ConfigBufferGrowth(false)
|
||||
err := h.ParseBytes(false, []byte(raw.String()))
|
||||
if !errors.Is(err, ErrHeaderTooMany) {
|
||||
t.Fatalf("want ErrHeaderFieldsTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// EqualDecodedPercentURL must agree with CopyDecodedPercentURL on every input:
|
||||
// same decoded bytes, and false wherever the copying decoder reports an error.
|
||||
func TestEqualDecodedPercentURL(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"", "plain", "a+b", "a%20b", "%41%42", "100%25", "a%2Fb", "+", "%2b",
|
||||
"trailing%", "trailing%4", "%zz", "a%2", "%%", "a+b%20c", "%00",
|
||||
} {
|
||||
for _, plusAsSpace := range []bool{false, true} {
|
||||
dst := make([]byte, len(value))
|
||||
n, err := CopyDecodedPercentURL(dst, []byte(value), plusAsSpace)
|
||||
// The copying decoder is the reference: whatever it produces is what
|
||||
// an equal comparison must accept, and only that.
|
||||
want := ""
|
||||
if err == nil {
|
||||
want = string(dst[:n])
|
||||
}
|
||||
got := EqualDecodedPercentURL([]byte(value), want, plusAsSpace)
|
||||
if err != nil {
|
||||
if got {
|
||||
t.Errorf("%q plus=%v: malformed escape must not compare equal", value, plusAsSpace)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("%q plus=%v: want equal to its own decoding %q", value, plusAsSpace, want)
|
||||
}
|
||||
if EqualDecodedPercentURL([]byte(value), want+"x", plusAsSpace) {
|
||||
t.Errorf("%q plus=%v: must not equal a longer want", value, plusAsSpace)
|
||||
}
|
||||
if want != "" && EqualDecodedPercentURL([]byte(value), want[:len(want)-1], plusAsSpace) {
|
||||
t.Errorf("%q plus=%v: must not equal a shorter want", value, plusAsSpace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The comparison must not allocate: it is the reason a query lookup can return
|
||||
// a view without scratch space.
|
||||
func TestEqualDecodedPercentURLNoAlloc(t *testing.T) {
|
||||
value := []byte("a%20long%2Dish+key")
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
EqualDecodedPercentURL(value, "a long-ish key", true)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Fatalf("EqualDecodedPercentURL allocated %v times, want 0", allocs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// kvBuffer is a common key-value store engine for Cookie, Form, Header and other HTTP abstractions that need
|
||||
// a key-value store with underlying buffer memory.
|
||||
type kvBuffer struct {
|
||||
buf []byte
|
||||
kvs []pairKV
|
||||
flags Flags
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) free() int { return cap(kvb.buf) - len(kvb.buf) }
|
||||
|
||||
// BufferRaw returns the underlying buffer, its length being the portion in use.
|
||||
// Stored pairs alias it, so writing to it mangles them.
|
||||
func (kvb *kvBuffer) BufferRaw() []byte { return kvb.buf }
|
||||
|
||||
// BufferUsed returns the raw memory used, which is what a caller appending from
|
||||
// several sources checks to know whether a separator is needed. Counts buffered
|
||||
// bytes and not parsed pairs, so it is set before a Parse and unchanged by one.
|
||||
func (kvb *kvBuffer) BufferUsed() int { return len(kvb.buf) }
|
||||
|
||||
// EnableBufferGrowth allows the buffer to grow past the memory [kvBuffer.Reset]
|
||||
// was handed. The setting outlives Reset; with growth off callers get [ErrBufferExhausted].
|
||||
func (kvb *kvBuffer) EnableBufferGrowth(enableGrowth bool) {
|
||||
if enableGrowth {
|
||||
kvb.flags &^= flagNoBufferGrow
|
||||
} else {
|
||||
kvb.flags |= flagNoBufferGrow
|
||||
}
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) discardKVs() { kvb.kvs = kvb.kvs[:0] }
|
||||
|
||||
// BufferGrowthEnabled reports whether the buffer may grow, see [kvBuffer.EnableBufferGrowth].
|
||||
func (kvb *kvBuffer) BufferGrowthEnabled() bool { return !kvb.flags.HasAny(flagNoBufferGrow) }
|
||||
|
||||
// ReadFromBytes appends buf to the underlying buffer, accumulating data to parse.
|
||||
// Returns [ErrBufferExhausted] when buf does not fit and growth is disabled.
|
||||
func (kvb *kvBuffer) ReadFromBytes(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
return io.ErrNoProgress // Nothing handed over, not a buffer problem.
|
||||
} else if kvb.flags.HasAny(flagMangledBuffer) {
|
||||
return errMangledBuffer
|
||||
} else if len(buf)+cap(kvb.buf) > maxBufLen {
|
||||
return ErrBufferExhausted
|
||||
}
|
||||
free := kvb.free()
|
||||
if len(buf) > free && !kvb.BufferGrowthEnabled() {
|
||||
return ErrBufferExhausted
|
||||
}
|
||||
kvb.buf = append(kvb.buf, buf...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLimited appends at most limit bytes read from r to the underlying buffer.
|
||||
// A read returning data alongside [io.EOF] reports a nil error, later ones io.EOF.
|
||||
func (kvb *kvBuffer) ReadLimited(r io.Reader, limit int) (int, error) {
|
||||
free := kvb.free()
|
||||
growthEnabled := kvb.BufferGrowthEnabled()
|
||||
if !growthEnabled && (free == 0 || free < limit) || len(kvb.buf) >= maxBufLen {
|
||||
return 0, ErrBufferExhausted
|
||||
} else if kvb.flags.HasAny(flagMangledBuffer) {
|
||||
return 0, errMangledBuffer
|
||||
} else if kvb.flags.HasAny(flagReaderEOF) {
|
||||
return 0, io.EOF
|
||||
} else if limit <= 0 {
|
||||
return 0, io.ErrNoProgress
|
||||
}
|
||||
kvb.buf = slices.Grow(kvb.buf, limit)
|
||||
n, err := r.Read(kvb.buf[len(kvb.buf):min(len(kvb.buf)+limit, maxBufLen)])
|
||||
kvb.buf = kvb.buf[:len(kvb.buf)+n]
|
||||
if err != nil {
|
||||
if n > 0 && err == io.EOF {
|
||||
kvb.flags |= flagReaderEOF
|
||||
err = nil // Nil out EOF to not scare off readers.
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Reset discards all pairs and takes buf as the buffer to parse in place, nil
|
||||
// reusing the current one. kvCap sizes the pair table. Only the growth setting survives.
|
||||
func (kvb *kvBuffer) Reset(buf []byte, kvCap int) {
|
||||
if buf == nil {
|
||||
kvb.buf = kvb.buf[:0]
|
||||
} else {
|
||||
kvb.buf = buf
|
||||
}
|
||||
internal.SliceReuse(&kvb.kvs, kvCap)
|
||||
kvb.flags = kvb.flags & flagNoBufferGrow // Only flag persisted is buffer grow config.
|
||||
}
|
||||
|
||||
// CopyFrom replaces the receiver's contents with a copy of src, sharing no
|
||||
// memory with it afterwards.
|
||||
func (kvb *kvBuffer) CopyFrom(src *kvBuffer) {
|
||||
kvb.buf = append(kvb.buf[:0], src.buf...)
|
||||
kvb.kvs = append(kvb.kvs[:0], src.kvs...)
|
||||
}
|
||||
|
||||
// Get returns the value of the first pair matching key.
|
||||
// Bytes are compared as stored, so if using a Form call [Form.Decode] first when keys may be encoded.
|
||||
// Returns nil for an absent key and for a valueless pair alike, so use
|
||||
// [kvBuffer.Present] to tell the two apart.
|
||||
func (kvb *kvBuffer) Get(key string) []byte {
|
||||
i := kvb.getIdx(key)
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
return kvb.AtValue(i)
|
||||
}
|
||||
|
||||
// GetFold returns the value of the first key that matches ascii-case-insensitive.
|
||||
func (kvb *kvBuffer) GetFold(key string) []byte {
|
||||
i := kvb.getFoldIdx(key)
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
return kvb.AtValue(i)
|
||||
}
|
||||
|
||||
// ForEach iterates over the cookie's key-value pairs as stored until cb returns false.
|
||||
func (kvb *kvBuffer) ForEach(cb func(key, value []byte) bool) {
|
||||
nc := len(kvb.kvs)
|
||||
for i := range nc {
|
||||
if !kvb.kvs[i].isValid() {
|
||||
continue
|
||||
} else if !cb(kvb.At(i)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Has returns true if key is present, with or without a value.
|
||||
func (kvb *kvBuffer) Present(key string) bool { // TODO: rename to Has.
|
||||
return kvb.getIdx(key) >= 0
|
||||
}
|
||||
|
||||
// Has returns true if key is present, with or without a value.
|
||||
func (kvb *kvBuffer) HasKeyValue(key, value string) bool {
|
||||
idx := kvb.getIdx(key)
|
||||
if idx >= 0 {
|
||||
return b2s(kvb.musttoken(kvb.kvs[idx].value)) == value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Add appends a pair, keeping any already sharing the key: use [kvBuffer.Set]
|
||||
// to replace instead. Reports false if the buffer could not hold it.
|
||||
func (kvb *kvBuffer) Add(key, value string) (enoughSpace bool) {
|
||||
kvb.appendPair(key, value)
|
||||
return kvb.getIdx(key) >= 0
|
||||
}
|
||||
|
||||
// Set replaces key's value and invalidates every other pair sharing the key, so
|
||||
// a following [kvBuffer.Get] sees exactly one value.
|
||||
//
|
||||
// It rewrites in place when it can: of the pairs it would invalidate it keeps
|
||||
// the smallest whose key and value regions both still hold the new pair,
|
||||
// leaving the roomier regions for a later Set. When none fits the pair is
|
||||
// appended with [kvBuffer.Add] and the invalidated regions are stranded, since
|
||||
// nothing here compacts the buffer.
|
||||
func (kvb *kvBuffer) Set(key, value string) (enoughSpace bool) {
|
||||
reuse := kvb.takeReusableSlot(key, len(key), len(value))
|
||||
if reuse < 0 {
|
||||
return kvb.Add(key, value)
|
||||
}
|
||||
kvb.overwriteAt(reuse, key, value)
|
||||
return true
|
||||
}
|
||||
|
||||
// SetInt is [kvBuffer.Set]'s integer counterpart. It formats value straight into
|
||||
// the slot it reuses, so overwriting a pair never allocates.
|
||||
func (kvb *kvBuffer) SetInt(key string, value int64, base int) (enoughSpace bool) {
|
||||
reuse := kvb.takeReusableSlot(key, len(key), internal.IntLen(value, base))
|
||||
if reuse < 0 {
|
||||
return kvb.appendPairInt(key, value, base)
|
||||
}
|
||||
kvb.flags |= flagMangledBuffer
|
||||
kv := &kvb.kvs[reuse]
|
||||
copy(kvb.buf[kv.key.start:], key)
|
||||
kv.key.len = tokint(len(key))
|
||||
// The slot was picked to hold keyLen/valueLen, so AppendInt writes inside
|
||||
// buf and never grows a new backing array.
|
||||
v := strconv.AppendInt(kvb.buf[kv.value.start:kv.value.start], value, base)
|
||||
kv.value.len = tokint(len(v))
|
||||
return true
|
||||
}
|
||||
|
||||
// takeReusableSlot invalidates every pair matching key except the smallest one
|
||||
// whose key and value regions hold keyLen and valueLen bytes, whose index it
|
||||
// returns. It returns -1 when no surviving slot fits, meaning the caller must
|
||||
// append instead.
|
||||
func (kvb *kvBuffer) takeReusableSlot(key string, keyLen, valueLen int) int {
|
||||
reuse := -1
|
||||
for i := range kvb.kvs {
|
||||
kv := &kvb.kvs[i]
|
||||
if !kv.isValid() || b2s(kvb.musttoken(kv.key)) != key {
|
||||
continue
|
||||
}
|
||||
// A valueless pair holds no value region, so reusing one would write the
|
||||
// value over byte 0. Let it fall through to the caller's append, which
|
||||
// gives the pair a real region and keeps "ok" distinct from "ok=".
|
||||
fits := kv.HasValue() && int(kv.key.len) >= keyLen && int(kv.value.len) >= valueLen
|
||||
if fits && (reuse < 0 || kv.size() < kvb.kvs[reuse].size()) {
|
||||
if reuse >= 0 {
|
||||
kvb.kvs[reuse].invalidate() // Superseded by a tighter fit.
|
||||
}
|
||||
reuse = i
|
||||
continue
|
||||
}
|
||||
kv.invalidate()
|
||||
}
|
||||
return reuse
|
||||
}
|
||||
|
||||
// overwriteAt writes key and value over the regions pair i already owns. The
|
||||
// caller must have checked both fit; the bytes freed by a shorter pair are
|
||||
// stranded, not reclaimed.
|
||||
func (kvb *kvBuffer) overwriteAt(i int, key, value string) {
|
||||
kvb.flags |= flagMangledBuffer
|
||||
kv := &kvb.kvs[i]
|
||||
copy(kvb.buf[kv.key.start:], key)
|
||||
kv.key.len = tokint(len(key))
|
||||
copy(kvb.buf[kv.value.start:], value)
|
||||
kv.value.len = tokint(len(value))
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) setInternal(key, value []byte) (enoughSpace bool) {
|
||||
if !kvb.canAddOneKV() {
|
||||
return false
|
||||
}
|
||||
kvb.flags |= flagKVAppended
|
||||
kvb.kvs = append(kvb.kvs, pairKV{
|
||||
key: kvb.view(key),
|
||||
value: kvb.view(value),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Len returns the number of slots stored, counting those [kvBuffer.Set] invalidated.
|
||||
func (kvb *kvBuffer) Len() int { return len(kvb.kvs) }
|
||||
|
||||
// At returns the i'th pair in wire order. value is nil for a pair holding none,
|
||||
// which is what tells a form's "ok" from "ok=".
|
||||
func (kvb *kvBuffer) At(i int) (key, value []byte) {
|
||||
kv := kvb.kvs[i]
|
||||
if !kv.HasValue() {
|
||||
return kvb.musttoken(kv.key), nil
|
||||
}
|
||||
return kvb.musttoken(kv.key), kvb.musttoken(kv.value)
|
||||
}
|
||||
func (kvb *kvBuffer) setAt(i int, k, v []byte) {
|
||||
kvb.flags |= flagMangledBuffer
|
||||
// Route through slice, not bytes2tok: a nil v is a pair with no '=' and must
|
||||
// stay absent rather than trip the alias check on a nil pointer.
|
||||
kvb.kvs[i] = pairKV{
|
||||
key: kvb.view(k),
|
||||
value: kvb.view(v),
|
||||
}
|
||||
}
|
||||
|
||||
// AtKey is [kvBuffer.At] limited to the i'th key.
|
||||
func (kvb *kvBuffer) AtKey(i int) (key []byte) { return kvb.musttoken(kvb.kvs[i].key) }
|
||||
|
||||
// AtValue is [kvBuffer.At] limited to the i'th value, nil when the pair holds none.
|
||||
func (kvb *kvBuffer) AtValue(i int) (key []byte) {
|
||||
if !kvb.kvs[i].HasValue() {
|
||||
return nil
|
||||
}
|
||||
return kvb.musttoken(kvb.kvs[i].value)
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) getIdx(key string) int {
|
||||
for i, pair := range kvb.kvs {
|
||||
if pair.isValid() && b2s(kvb.musttoken(pair.key)) == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) getFoldIdx(key string) int {
|
||||
for i, pair := range kvb.kvs {
|
||||
if pair.isValid() && EqualFoldASCII(key, b2s(kvb.AtKey(i))) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// EqualFoldASCII reports whether a and b are equal under ASCII case folding.
|
||||
// Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte
|
||||
// rune such as U+212A KELVIN SIGN can alias a header key.
|
||||
func EqualFoldASCII(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
const asciiCapDiff = 'a' - 'A'
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca, cb := a[i], b[i]
|
||||
if ca >= 'A' && ca <= 'Z' {
|
||||
ca += asciiCapDiff
|
||||
}
|
||||
if cb >= 'A' && cb <= 'Z' {
|
||||
cb += asciiCapDiff
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reserve ensures need free bytes are available in the buffer, growing it when
|
||||
// permitted. It accounts for the byte-0 reservation on an empty buffer (see
|
||||
// mustAppendSlice). It returns false and sets flagOOMReached when the space
|
||||
// cannot be guaranteed: a tokint offset overflow, or a full buffer with
|
||||
// flagNoBufferGrow set.
|
||||
func (kvb *kvBuffer) reserve(need int) (enoughSpace bool) {
|
||||
if len(kvb.buf) == 0 {
|
||||
need++ // mustAppend* reserves byte 0 on an empty buffer.
|
||||
}
|
||||
if len(kvb.buf)+need > maxBufLen {
|
||||
kvb.flags |= flagOOMReached // Offsets would overflow uint16 tokint.
|
||||
return false
|
||||
}
|
||||
if need > kvb.free() {
|
||||
if kvb.flags.HasAny(flagNoBufferGrow) {
|
||||
kvb.flags |= flagOOMReached
|
||||
return false
|
||||
}
|
||||
kvb.buf = slices.Grow(kvb.buf, need)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) appendPair(key, value string) bool {
|
||||
if !kvb.canAddOneKV() || !kvb.reserve(len(key)+len(value)) {
|
||||
return false
|
||||
}
|
||||
kvb.flags |= flagKVAppended
|
||||
kvb.kvs = append(kvb.kvs, pairKV{
|
||||
key: kvb.mustAppendSlice(key),
|
||||
value: kvb.mustAppendSlice(value),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) appendPairInt(key string, value int64, base int) bool {
|
||||
vlen := internal.IntLen(value, base)
|
||||
if !kvb.canAddOneKV() || !kvb.reserve(len(key)+vlen) {
|
||||
return false
|
||||
}
|
||||
kvb.flags |= flagKVAppended
|
||||
kvb.kvs = append(kvb.kvs, pairKV{
|
||||
key: kvb.mustAppendSlice(key),
|
||||
value: kvb.mustAppendInt(value, base),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) canAddOneKV() (enoughSpace bool) {
|
||||
return len(kvb.kvs) < cap(kvb.kvs) || kvb.flags&flagNoBufferGrow == 0
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) mustAppendSlice(value string) view {
|
||||
L := len(kvb.buf)
|
||||
if L == 0 {
|
||||
L++ // Valid key-values start after 0.
|
||||
}
|
||||
copy(kvb.buf[L:L+len(value)], value)
|
||||
kvb.buf = kvb.buf[:L+len(value)]
|
||||
return kvb.view(kvb.buf[L : L+len(value)])
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) mustAppendInt(value int64, base int) view {
|
||||
L := len(kvb.buf)
|
||||
if L == 0 {
|
||||
L++ // Valid key-values start after byte 0.
|
||||
}
|
||||
v := strconv.AppendInt(kvb.buf[L:L], value, base)
|
||||
kvb.buf = kvb.buf[:L+len(v)]
|
||||
return kvb.view(kvb.buf[L : L+len(v)])
|
||||
}
|
||||
|
||||
// reuseOrAppend writes value over tok's slot when it fits there, avoiding any
|
||||
// buffer growth; otherwise it appends a fresh slot.
|
||||
func (kvb *kvBuffer) reuseOrAppend(tok view, value string) view {
|
||||
if tok.len > tokint(len(value)) {
|
||||
copy(kvb.musttoken(tok), value)
|
||||
tok.len = tokint(len(value))
|
||||
return tok
|
||||
}
|
||||
return kvb.appendSlice(value)
|
||||
}
|
||||
|
||||
// appendSlice reserves space (growing or flagging OOM) and appends value as a
|
||||
// new slot.
|
||||
func (kvb *kvBuffer) appendSlice(value string) view {
|
||||
debuglog("http:appendslice:start")
|
||||
if !kvb.reserve(len(value)) {
|
||||
return view{} // Drop and flag OOM; never panic.
|
||||
}
|
||||
kvb.flags |= flagMangledBuffer
|
||||
return kvb.mustAppendSlice(value)
|
||||
}
|
||||
|
||||
// reuseOrAppendInt is [kvBuffer.reuseOrAppend]'s integer counterpart.
|
||||
func (kvb *kvBuffer) reuseOrAppendInt(tok view, value int64, base int) view {
|
||||
n := internal.IntLen(value, base)
|
||||
if int(tok.len) >= n {
|
||||
// Reuse: format directly over the existing slot. No free space needed
|
||||
// since n <= tok.len and the slot already lives inside buf.
|
||||
v := strconv.AppendInt(kvb.buf[tok.start:tok.start], value, base)
|
||||
tok.len = tokint(len(v))
|
||||
kvb.flags |= flagMangledBuffer
|
||||
return tok
|
||||
}
|
||||
return kvb.appendInt(value, base, n)
|
||||
}
|
||||
|
||||
// appendInt reserves space (growing or flagging OOM) and appends value as a new slot.
|
||||
func (kvb *kvBuffer) appendInt(value int64, base, n int) view {
|
||||
if !kvb.reserve(n) {
|
||||
return view{} // Drop and flag OOM; never panic.
|
||||
}
|
||||
kvb.flags |= flagMangledBuffer
|
||||
return kvb.mustAppendInt(value, base)
|
||||
}
|
||||
|
||||
func (kvb *kvBuffer) view(value []byte) view {
|
||||
if value == nil {
|
||||
return view{}
|
||||
}
|
||||
return bytes2tok(kvb.buf, value)
|
||||
}
|
||||
|
||||
func (kvb kvBuffer) musttoken(slice view) []byte {
|
||||
return tok2bytes(kvb.buf, slice)
|
||||
}
|
||||
func (kvb *kvBuffer) noKV() pairKV { return pairKV{} }
|
||||
|
||||
type tokint = uint16
|
||||
|
||||
// view is a smaller `string`-like representation of a section in [kvBuffer]'s buffer.
|
||||
type view struct {
|
||||
start tokint
|
||||
len tokint
|
||||
}
|
||||
|
||||
type pairKV struct {
|
||||
key view
|
||||
value view // value start >0 means value is present.
|
||||
}
|
||||
|
||||
// isValid is for stores parsed in place, where offset 0 is the first key so
|
||||
// only length can signal presence. Empty keys are valid: see valueless cookies.
|
||||
func (pair pairKV) isValid() bool {
|
||||
return pair.key.len > 0 || pair.value.len > 0
|
||||
}
|
||||
|
||||
// isValidHeader is for the append-built [HeaderV1] store, where mustAppendSlice
|
||||
// burns byte 0 so a zero offset means absent. Drops offset-0 pairs otherwise.
|
||||
func (pair pairKV) isValidHeader() bool { return pair.key.start > 0 }
|
||||
|
||||
func (pair *pairKV) invalidate() {
|
||||
*pair = pairKV{}
|
||||
}
|
||||
|
||||
// size is the buffer a pair occupies, used to pick the tightest slot to reuse.
|
||||
func (pair pairKV) size() int { return int(pair.key.len) + int(pair.value.len) }
|
||||
|
||||
func (pair pairKV) HasValue() bool { return pair.value.start > 0 }
|
||||
|
||||
// b2s converts byte slice to a string without memory allocation.
|
||||
// See https://groups.google.com/forum/#!msg/Golang-Nuts/ENgbUzYvCuU/90yGx7GUAgAJ .
|
||||
func b2s(b []byte) string {
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
func tok2bytes(buf []byte, slice view) []byte {
|
||||
return buf[slice.start : slice.start+slice.len]
|
||||
}
|
||||
|
||||
func bytes2tok(buf, value []byte) view {
|
||||
base := uintptr(unsafe.Pointer(unsafe.SliceData(buf)))
|
||||
off := uintptr(unsafe.Pointer(unsafe.SliceData(value)))
|
||||
if off < base || off > base+uintptr(len(buf)) {
|
||||
panic("httpx: argument buffer does not alias header buffer")
|
||||
}
|
||||
return view{
|
||||
start: tokint(off - base),
|
||||
len: tokint(len(value)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Multipart splits a "multipart/form-data" body into its parts. Such bodies
|
||||
// frame their fields with a delimiter instead of escaping them, so a part's
|
||||
// value has no length: it ends where the next delimiter begins.
|
||||
//
|
||||
// Multipart stores none of the body, leaving the caller to decide what to keep,
|
||||
// what to skip and when a part has grown too large. Both methods report how much
|
||||
// of buf they consumed, which the caller compacts away before reading more in:
|
||||
//
|
||||
// var m httpraw.Multipart
|
||||
// m.SetContentType(contentType)
|
||||
// var hdr httpraw.MultipartHeader
|
||||
// for {
|
||||
// parsed, err := m.NextHeader(&hdr, buf[:buflen])
|
||||
// if err != nil {
|
||||
// break // io.EOF at the closing delimiter, body done.
|
||||
// } else if parsed == 0 {
|
||||
// // Header block incomplete: read more into buf[buflen:] and retry.
|
||||
// continue
|
||||
// }
|
||||
// buflen = copy(buf, buf[parsed:buflen])
|
||||
// for {
|
||||
// bodyLen, restOff, done := m.NextBody(buf[:buflen])
|
||||
// // Consume buf[:bodyLen] for hdr.Name, then compact and read more.
|
||||
// buflen = copy(buf, buf[restOff:buflen])
|
||||
// if done {
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
type Multipart struct {
|
||||
// Boundary is the delimiter parameter of the body's Content-Type field,
|
||||
// without the leading "--" the delimiter carries on the wire.
|
||||
Boundary []byte
|
||||
}
|
||||
|
||||
// MultipartHeader is a part's header block and the Content-Disposition
|
||||
// parameters that identify it.
|
||||
type MultipartHeader struct {
|
||||
// PartView is the part's raw header block, ending in its final CRLF. It
|
||||
// aliases the buffer it was parsed from, so it is only valid until that
|
||||
// buffer is compacted or read into again.
|
||||
PartView []byte
|
||||
// Name is the name parameter of a part's Content-Disposition field,
|
||||
// i.e: "photo" for `form-data; name="photo"; filename="beach.png"`.
|
||||
// Copied out of the buffer, so it outlives it, and reused between parts.
|
||||
Name []byte
|
||||
// Filename is the filename parameter of a part's Content-Disposition
|
||||
// field, empty when the part is not a file upload. Copied like Name.
|
||||
Filename []byte
|
||||
}
|
||||
|
||||
// Reset clears the header for the next part, keeping the buffers Name and
|
||||
// Filename were copied into so a reused header stops allocating.
|
||||
func (hdr *MultipartHeader) Reset() {
|
||||
hdr.PartView = nil
|
||||
hdr.Name = hdr.Name[:0]
|
||||
hdr.Filename = hdr.Filename[:0]
|
||||
}
|
||||
|
||||
// SetContentType sets [Multipart.Boundary] from the boundary parameter of a
|
||||
// Content-Type field value, i.e: "abc123" for
|
||||
// "multipart/form-data; boundary=abc123". The leading "--" the delimiter carries
|
||||
// on the wire is not included. Fails when the parameter is absent or is not
|
||||
// 1 to 70 characters long, RFC 2046 5.1.1; a zero length boundary would match
|
||||
// every "--" in the body.
|
||||
func (m *Multipart) SetContentType(contentType []byte) error {
|
||||
m.Boundary = ContentParam(contentType, "boundary")
|
||||
if len(m.Boundary) == 0 || len(m.Boundary) > 70 {
|
||||
return errNoBoundary // RFC 2046 5.1.1: 1..70 characters, required.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextHeader parses the leading part's header block off a multipart body into
|
||||
// dst, returning how many bytes of data it consumed: the part's content begins
|
||||
// at data[parsedLen]. A zero parsedLen and no error means data holds no complete
|
||||
// delimiter and header block yet, so the caller reads more in and retries.
|
||||
// Returns [io.EOF] once the closing delimiter is reached. dst is reset on error.
|
||||
func (m *Multipart) NextHeader(dst *MultipartHeader, data []byte) (parsedLen int, err error) {
|
||||
dst.Reset()
|
||||
if len(m.Boundary) == 0 {
|
||||
return 0, errNoBoundary
|
||||
}
|
||||
idx := m.indexDelimiter(data)
|
||||
if idx < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
after := idx + len("--") + len(m.Boundary)
|
||||
if after+2 > len(data) {
|
||||
return 0, nil // Cannot tell a closing delimiter yet.
|
||||
} else if data[after] == '-' && data[after+1] == '-' {
|
||||
return 0, io.EOF
|
||||
}
|
||||
// Delimiter is followed by CRLF, then the part's header block.
|
||||
if data[after] == '\r' {
|
||||
after++
|
||||
}
|
||||
if after >= len(data) {
|
||||
return 0, nil
|
||||
} else if data[after] != '\n' {
|
||||
return 0, errBadDelimiter
|
||||
}
|
||||
after++
|
||||
end := bytes.Index(data[after:], []byte("\r\n\r\n"))
|
||||
if end < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
dst.PartView = data[after : after+end+2]
|
||||
disposition := partField(dst.PartView)
|
||||
dst.Name = append(dst.Name[:0], ContentParam(disposition, "name")...)
|
||||
dst.Filename = append(dst.Filename[:0], ContentParam(disposition, "filename")...)
|
||||
return after + end + 4, nil
|
||||
}
|
||||
|
||||
// NextBody reports how much of data is part content, data[:bodyLen], and where
|
||||
// what is left begins, data[restOff:], which the caller compacts to the front of
|
||||
// its buffer before reading more in. done reports the part ended, in which case
|
||||
// data[restOff:] begins the next part's delimiter; otherwise the bytes past
|
||||
// bodyLen are a tail held back because it could still turn into a delimiter.
|
||||
func (m *Multipart) NextBody(data []byte) (bodyLen, restOff int, done bool) {
|
||||
idx := m.indexPartEnd(data)
|
||||
if idx >= 0 {
|
||||
return idx, idx + len("\r\n"), true
|
||||
}
|
||||
// Longest prefix of "\r\n--"+boundary that could still be completed.
|
||||
hold := min(len("\r\n--")+len(m.Boundary)-1, len(data))
|
||||
return len(data) - hold, len(data) - hold, false
|
||||
}
|
||||
|
||||
// indexDelimiter returns the offset of the leading "--"+Boundary in data.
|
||||
func (m *Multipart) indexDelimiter(data []byte) int {
|
||||
for i := 0; i+len("--")+len(m.Boundary) <= len(data); i++ {
|
||||
dash := bytes.IndexByte(data[i:], '-')
|
||||
if dash < 0 {
|
||||
return -1
|
||||
}
|
||||
i += dash
|
||||
if i+len("--")+len(m.Boundary) > len(data) {
|
||||
return -1
|
||||
}
|
||||
if data[i+1] == '-' && b2s(data[i+2:i+2+len(m.Boundary)]) == b2s(m.Boundary) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// indexPartEnd returns the offset of the CRLF that closes a part, that is the
|
||||
// CRLF preceding the next delimiter.
|
||||
func (m *Multipart) indexPartEnd(data []byte) int {
|
||||
for i := 0; i+len("\r\n--")+len(m.Boundary) <= len(data); i++ {
|
||||
cr := bytes.IndexByte(data[i:], '\r')
|
||||
if cr < 0 {
|
||||
return -1
|
||||
}
|
||||
i += cr
|
||||
if i+len("\r\n--")+len(m.Boundary) > len(data) {
|
||||
return -1
|
||||
}
|
||||
if data[i+1] == '\n' && data[i+2] == '-' && data[i+3] == '-' &&
|
||||
b2s(data[i+4:i+4+len(m.Boundary)]) == b2s(m.Boundary) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// MediaTypeIs reports whether a Content-Type field value carries the given
|
||||
// media type, ignoring case and any parameters that follow it, i.e: true for
|
||||
// "text/plain; charset=utf-8" and media type "text/plain". mediaType must be
|
||||
// ASCII lowercase. RFC 9110 8.3.1.
|
||||
func MediaTypeIs(value []byte, mediaType string) bool {
|
||||
if semi := bytes.IndexByte(value, ';'); semi >= 0 {
|
||||
value = value[:semi]
|
||||
}
|
||||
return equalFold(trimOWS(value), mediaType)
|
||||
}
|
||||
|
||||
// ContentParam returns the value of a parameter of a header field value, i.e:
|
||||
// "utf-8" for key "charset" of "text/plain; charset=utf-8". Quoted values are
|
||||
// returned without their quotes and with escapes left as they appear on the
|
||||
// wire. Key matching is case insensitive, RFC 9110 5.6.6.
|
||||
func ContentParam(value []byte, key string) []byte {
|
||||
for len(value) > 0 {
|
||||
semi := bytes.IndexByte(value, ';')
|
||||
if semi < 0 {
|
||||
return nil // No parameters left.
|
||||
}
|
||||
value = trimOWS(value[semi+1:])
|
||||
eq := bytes.IndexByte(value, '=')
|
||||
if eq < 0 {
|
||||
return nil
|
||||
}
|
||||
gotKey := trimOWS(value[:eq])
|
||||
value = value[eq+1:]
|
||||
param := value
|
||||
if len(param) > 0 && param[0] == '"' {
|
||||
end := bytes.IndexByte(param[1:], '"')
|
||||
if end < 0 {
|
||||
return nil // Unterminated quoted string.
|
||||
}
|
||||
param, value = param[1:end+1], param[end+2:]
|
||||
} else {
|
||||
end := bytes.IndexByte(param, ';')
|
||||
if end >= 0 {
|
||||
param, value = param[:end], param[end:]
|
||||
} else {
|
||||
value = nil
|
||||
}
|
||||
param = trimOWS(param)
|
||||
}
|
||||
if equalFold(gotKey, key) {
|
||||
return param
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// partField returns the Content-Disposition field value of a part header block.
|
||||
func partField(partHdr []byte) []byte {
|
||||
const key = "content-disposition"
|
||||
for len(partHdr) > 0 {
|
||||
eol := bytes.IndexByte(partHdr, '\n')
|
||||
line := partHdr
|
||||
if eol >= 0 {
|
||||
line, partHdr = partHdr[:eol], partHdr[eol+1:]
|
||||
} else {
|
||||
partHdr = nil
|
||||
}
|
||||
colon := bytes.IndexByte(line, ':')
|
||||
if colon > 0 && equalFold(line[:colon], key) {
|
||||
return line[colon+1:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trimOWS trims optional whitespace off both ends of b, RFC 9110 5.6.3.
|
||||
func trimOWS(b []byte) []byte {
|
||||
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') {
|
||||
b = b[1:]
|
||||
}
|
||||
for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t') {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// equalFold compares b to the ASCII lowercase key, case insensitively.
|
||||
func equalFold(b []byte, key string) bool {
|
||||
if len(b) != len(key) {
|
||||
return false
|
||||
}
|
||||
const asciiCapDiff = 'a' - 'A'
|
||||
for i := range b {
|
||||
c := b[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += asciiCapDiff
|
||||
}
|
||||
if c != key[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A two part body: a text field and a PNG upload whose bytes contain CRLFs and
|
||||
// even the boundary text, which must not desync the parser.
|
||||
const (
|
||||
multiBoundary = "----abc123"
|
||||
multiBody = "------abc123\r\n" +
|
||||
"Content-Disposition: form-data; name=\"caption\"\r\n" +
|
||||
"\r\n" +
|
||||
"hi there\r\n" +
|
||||
"------abc123\r\n" +
|
||||
"Content-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n" +
|
||||
"Content-Type: image/png\r\n" +
|
||||
"\r\n" +
|
||||
"\x89PNG\r\n--not-the-boundary\r\n\x00\xff\r\n" +
|
||||
"------abc123--\r\n"
|
||||
)
|
||||
|
||||
func TestMultipartBoundary(t *testing.T) {
|
||||
var mp Multipart
|
||||
for _, test := range []struct {
|
||||
contentType string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{contentType: "multipart/form-data; boundary=abc123", want: "abc123"},
|
||||
{contentType: "multipart/form-data; boundary=\"a b\"", want: "a b"},
|
||||
{contentType: "multipart/form-data; charset=utf-8; boundary=xyz", want: "xyz"},
|
||||
{contentType: "multipart/form-data; BOUNDARY=xyz", want: "xyz"}, // Keys are case insensitive.
|
||||
{contentType: "multipart/form-data", wantErr: true}, // Absent, RFC 2046 5.1.1 requires it.
|
||||
{contentType: "application/x-www-form-urlencoded", wantErr: true}, // Not multipart at all.
|
||||
{contentType: "multipart/form-data; boundary=", wantErr: true}, // Empty matches every "--".
|
||||
} {
|
||||
err := mp.SetContentType([]byte(test.contentType))
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("%q: want error, got boundary %q", test.contentType, mp.Boundary)
|
||||
}
|
||||
continue
|
||||
} else if err != nil {
|
||||
t.Errorf("%q: %s", test.contentType, err)
|
||||
continue
|
||||
}
|
||||
got := string(mp.Boundary)
|
||||
if got != test.want {
|
||||
t.Errorf("%q: want %q, got %q", test.contentType, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTypeIs(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
media string
|
||||
want bool
|
||||
}{
|
||||
{value: "text/plain", media: "text/plain", want: true},
|
||||
{value: "text/plain; charset=utf-8", media: "text/plain", want: true},
|
||||
{value: "text/plain;charset=utf-8", media: "text/plain", want: true},
|
||||
{value: "Text/Plain", media: "text/plain", want: true}, // Case insensitive, RFC 9110 8.3.1.
|
||||
{value: " text/plain ; x=1", media: "text/plain", want: true},
|
||||
{value: "text/plain", media: "text/html"},
|
||||
{value: "text/plainish", media: "text/plain"}, // Prefix must not match.
|
||||
{value: "", media: "text/plain"},
|
||||
{value: "multipart/form-data; boundary=abc", media: "multipart/form-data", want: true},
|
||||
} {
|
||||
if got := MediaTypeIs([]byte(test.value), test.media); got != test.want {
|
||||
t.Errorf("%q is %q: want %v, got %v", test.value, test.media, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentParam(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{value: "text/plain; charset=utf-8", key: "charset", want: "utf-8"},
|
||||
{value: "text/plain;charset=utf-8", key: "charset", want: "utf-8"}, // No space.
|
||||
{value: "text/plain; charset=\"utf-8\"", key: "charset", want: "utf-8"},
|
||||
{value: "form-data; name=\"photo\"; filename=\"a;b.png\"", key: "filename", want: "a;b.png"},
|
||||
{value: "form-data; name=\"photo\"", key: "nope", want: ""},
|
||||
{value: "form-data; names=x; name=y", key: "name", want: "y"}, // Prefix must not match.
|
||||
{value: "text/plain", key: "charset", want: ""},
|
||||
} {
|
||||
got := ContentParam([]byte(test.value), test.key)
|
||||
if string(got) != test.want {
|
||||
t.Errorf("%q key %q: want %q, got %q", test.value, test.key, test.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextPartHeader(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
var hdr MultipartHeader
|
||||
parsed, err := m.NextHeader(&hdr, []byte(multiBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const wantHdr = "Content-Disposition: form-data; name=\"caption\"\r\n"
|
||||
if string(hdr.PartView) != wantHdr {
|
||||
t.Errorf("want header %q, got %q", wantHdr, hdr.PartView)
|
||||
}
|
||||
if string(hdr.Name) != "caption" {
|
||||
t.Errorf("want name %q, got %q", "caption", hdr.Name)
|
||||
}
|
||||
if len(hdr.Filename) != 0 {
|
||||
t.Errorf("want no filename for a non file part, got %q", hdr.Filename)
|
||||
}
|
||||
if !strings.HasPrefix(multiBody[parsed:], "hi there\r\n") {
|
||||
t.Errorf("want rest at part body, got %q", multiBody[parsed:])
|
||||
}
|
||||
}
|
||||
|
||||
// Names and filenames must outlive the buffer they were parsed from, so a
|
||||
// caller may compact it and read more without losing the part it is reading.
|
||||
func TestNextPartHeaderOutlivesBuffer(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
data := []byte(multiBody)
|
||||
var hdr MultipartHeader
|
||||
if _, err := m.NextHeader(&hdr, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range data {
|
||||
data[i] = 'x' // Buffer reused for the next read.
|
||||
}
|
||||
if string(hdr.Name) != "caption" {
|
||||
t.Errorf("want name %q to survive the buffer, got %q", "caption", hdr.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Incomplete data must ask for more, never guess.
|
||||
func TestNextPartHeaderNeedMore(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
for _, data := range []string{
|
||||
"",
|
||||
"------abc", // Delimiter cut short.
|
||||
"------abc123\r\n", // No header block yet.
|
||||
"------abc123\r\nContent-Disposition: form-", // Header block unterminated.
|
||||
} {
|
||||
var hdr MultipartHeader
|
||||
parsed, err := m.NextHeader(&hdr, []byte(data))
|
||||
if parsed != 0 || err != nil {
|
||||
t.Errorf("%q: want (0, nil) asking for more data, got (%d, %v)", data, parsed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Junk between the delimiter and the part header is a multipart framing error,
|
||||
// not a header field name error.
|
||||
func TestNextPartHeaderJunk(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte("abc")}
|
||||
var hdr MultipartHeader
|
||||
if _, err := m.NextHeader(&hdr, []byte("--abcX\r\nA: b\r\n\r\n")); err != errBadDelimiter {
|
||||
t.Errorf("want errBadDelimiter, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The closing delimiter ends iteration.
|
||||
func TestNextPartHeaderEnd(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
var hdr MultipartHeader
|
||||
if _, err := m.NextHeader(&hdr, []byte("------abc123--\r\n")); err != io.EOF {
|
||||
t.Errorf("want io.EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextPartBody(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
var hdr MultipartHeader
|
||||
parsed, err := m.NextHeader(&hdr, []byte(multiBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rest := multiBody[parsed:]
|
||||
bodyLen, restOff, done := m.NextBody([]byte(rest))
|
||||
if !done {
|
||||
t.Fatal("want the part to end within the buffer")
|
||||
}
|
||||
if rest[:bodyLen] != "hi there" {
|
||||
t.Errorf("want body %q, got %q", "hi there", rest[:bodyLen])
|
||||
}
|
||||
if !strings.HasPrefix(rest[restOff:], "------abc123\r\n") {
|
||||
t.Errorf("want rest at next delimiter, got %q", rest[restOff:])
|
||||
}
|
||||
}
|
||||
|
||||
// A part whose bytes contain CRLFs and boundary-like text must survive intact.
|
||||
func TestNextPartBodyBinary(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
rest := []byte(multiBody)
|
||||
var hdr MultipartHeader
|
||||
parsed, err := m.NextHeader(&hdr, rest) // caption part.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, restOff, _ := m.NextBody(rest[parsed:])
|
||||
rest = rest[parsed+restOff:]
|
||||
parsed, err = m.NextHeader(&hdr, rest) // photo part.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rest = rest[parsed:]
|
||||
bodyLen, restOff, done := m.NextBody(rest)
|
||||
if !done {
|
||||
t.Fatal("want the part to end within the buffer")
|
||||
}
|
||||
const want = "\x89PNG\r\n--not-the-boundary\r\n\x00\xff"
|
||||
if string(rest[:bodyLen]) != want {
|
||||
t.Errorf("want body %q, got %q", want, rest[:bodyLen])
|
||||
}
|
||||
if _, err = m.NextHeader(&hdr, rest[restOff:]); err != io.EOF {
|
||||
t.Errorf("want io.EOF after last part, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A delimiter split across two reads must not be mistaken for part data: the
|
||||
// tail is held back until proven not to be a delimiter.
|
||||
func TestNextPartBodySplitDelimiter(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
const part = "hi there"
|
||||
full := part + "\r\n------abc123\r\n"
|
||||
for split := 1; split < len(full); split++ {
|
||||
data := full[:split]
|
||||
bodyLen, restOff, done := m.NextBody([]byte(data))
|
||||
if done {
|
||||
continue // Whole delimiter already present, nothing to prove.
|
||||
}
|
||||
if bodyLen > len(part) {
|
||||
t.Fatalf("split %d: emitted %q, past the end of the part", split, data[:bodyLen])
|
||||
}
|
||||
if data[:bodyLen]+data[restOff:] != data {
|
||||
t.Fatalf("split %d: body+rest %q%q does not reconstruct input", split, data[:bodyLen], data[restOff:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file part carries both parameters, and the raw block stays available.
|
||||
func TestNextHeaderFilePart(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
var hdr MultipartHeader
|
||||
parsed, err := m.NextHeader(&hdr, []byte(multiBody)) // caption part.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rest := []byte(multiBody[parsed:])
|
||||
_, restOff, _ := m.NextBody(rest)
|
||||
if _, err = m.NextHeader(&hdr, rest[restOff:]); err != nil { // photo part.
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(hdr.Name); got != "photo" {
|
||||
t.Errorf("want name %q, got %q", "photo", got)
|
||||
}
|
||||
if got := string(hdr.Filename); got != "beach.png" {
|
||||
t.Errorf("want filename %q, got %q", "beach.png", got)
|
||||
}
|
||||
if !strings.Contains(string(hdr.PartView), "Content-Type: image/png") {
|
||||
t.Errorf("want the raw block to hold every field, got %q", hdr.PartView)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed call must not leave the previous part's fields behind.
|
||||
func TestNextHeaderResetsOnError(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
var hdr MultipartHeader
|
||||
if _, err := m.NextHeader(&hdr, []byte(multiBody)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.NextHeader(&hdr, []byte("------abc123--\r\n")); err != io.EOF {
|
||||
t.Fatalf("want io.EOF, got %v", err)
|
||||
}
|
||||
if hdr.PartView != nil || len(hdr.Name) != 0 || len(hdr.Filename) != 0 {
|
||||
t.Errorf("want cleared header on error, got %+v", hdr)
|
||||
}
|
||||
}
|
||||
|
||||
// A header reused across parts must stop allocating once its name and filename
|
||||
// buffers are big enough.
|
||||
func TestNextHeaderReuseNoAlloc(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
data := []byte(multiBody)
|
||||
var hdr MultipartHeader
|
||||
allocs := testing.AllocsPerRun(10, func() {
|
||||
if _, err := m.NextHeader(&hdr, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("want a reused header to allocate 0 times, got %v", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole loop, as a caller writes it over a buffer it compacts.
|
||||
func TestMultipartLoop(t *testing.T) {
|
||||
m := Multipart{Boundary: []byte(multiBoundary)}
|
||||
rest := []byte(multiBody)
|
||||
var got []string
|
||||
var hdr MultipartHeader
|
||||
for {
|
||||
parsed, err := m.NextHeader(&hdr, rest)
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if parsed == 0 {
|
||||
t.Fatal("header must complete within the buffer")
|
||||
}
|
||||
name := string(hdr.Name)
|
||||
total := 0
|
||||
rest = rest[parsed:]
|
||||
for {
|
||||
bodyLen, restOff, done := m.NextBody(rest)
|
||||
total += bodyLen
|
||||
rest = rest[restOff:]
|
||||
if done {
|
||||
break
|
||||
}
|
||||
t.Fatal("part must complete within the buffer")
|
||||
}
|
||||
got = append(got, name+":"+strconv.Itoa(total))
|
||||
}
|
||||
want := "caption:8|photo:28"
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("want %q, got %q", want, strings.Join(got, "|"))
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoProto = errors.New("missing protocol, HTTP/0.9 unsupported")
|
||||
errNeedMore = errors.New("need more data: cannot find trailing lf")
|
||||
errUnparsed = errors.New("need to finish parsing")
|
||||
errInvalidName = errors.New("invalid header name")
|
||||
errSmallBuffer = errors.New("small read buffer. Increase ReadBufferSize")
|
||||
errOOM = errors.New("httpraw: buffer out of memory")
|
||||
// Header.Set and Header.Add mangles the buffer.
|
||||
// Call them after retrieving the Body. Do not call them before parsing the header (why would you even do that?).
|
||||
errMangledBuffer = errors.New("httpraw: mangled buffer")
|
||||
errNoCookies = errors.New("no cookie found")
|
||||
errEmptyURI = errors.New("empty URI")
|
||||
errLongStatusCode = errors.New("long status code")
|
||||
errBadStatusCode = errors.New("invalid status code")
|
||||
errAlreadyParsed = errors.New("TryParse called after header parsed")
|
||||
errNeedMethodURI = errors.New("need method/request URI to create request header")
|
||||
errBadStatusCodeTxt = errors.New("invalid status code or text")
|
||||
errCookiesParsed = errors.New("cookies already parsed, reset before parsing again")
|
||||
)
|
||||
|
||||
type headerBuf struct {
|
||||
// buf[:len] holds entire HTTP header data, which may be normalized by [flags]. buf[off:len] holds data not yet processed during parsing.
|
||||
buf []byte
|
||||
// offset into buf for parsing.
|
||||
off int
|
||||
// args contains key-value store.
|
||||
headers []argsKV
|
||||
}
|
||||
|
||||
// reset sets the buffer data and discards all parsed data.
|
||||
func (h *headerBuf) reset(buf []byte) {
|
||||
if buf == nil {
|
||||
buf = h.buf[:0] // Reuse buffer but discard raw data on nil input.
|
||||
}
|
||||
if cap(h.headers) == 0 {
|
||||
h.headers = make([]argsKV, 16)
|
||||
}
|
||||
*h = headerBuf{
|
||||
buf: buf,
|
||||
headers: h.headers[:0],
|
||||
}
|
||||
}
|
||||
|
||||
type tokint = uint16
|
||||
|
||||
type headerSlice struct {
|
||||
start tokint
|
||||
len tokint
|
||||
}
|
||||
|
||||
type argsKV struct {
|
||||
key headerSlice
|
||||
value headerSlice // value start >0 means value is present.
|
||||
}
|
||||
|
||||
type scannerState struct {
|
||||
err error
|
||||
|
||||
// by checking whether the next line contains a colon or not to tell
|
||||
// it's a header entry or a multi line value of current header entry.
|
||||
// the side effect of this operation is that we know the index of the
|
||||
// next colon and new line, so this can be used during next iteration,
|
||||
// instead of find them again.
|
||||
nextColon int
|
||||
nextNewLine int
|
||||
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func (h *Header) parse(asResponse bool) (err error) {
|
||||
debuglog("http:firstline:start")
|
||||
err = h.parseFirstLine(asResponse)
|
||||
if err != nil {
|
||||
debuglog("http:firstline:err")
|
||||
return err
|
||||
}
|
||||
debuglog("http:firstline:done")
|
||||
err = h.parseNextHeaders()
|
||||
debuglog("http:headers:done")
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Header) parseFirstLine(asResponse bool) (err error) {
|
||||
if asResponse {
|
||||
h.statusCode, h.statusText, h.flags, err = h.hbuf.parseFirstLineResponse(h.flags)
|
||||
} else {
|
||||
h.method, h.requestURI, h.proto, h.flags, err = h.hbuf.parseFirstLineRequest(h.flags)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Header) parseNextHeaders() error {
|
||||
var ss scannerState
|
||||
h.hbuf.parseNextHeaders(&ss)
|
||||
if ss.err != nil {
|
||||
h.flags |= flagConnClose
|
||||
return ss.err
|
||||
}
|
||||
h.flags |= flagDoneParsingHeader
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hb *headerBuf) readFromBytes(b []byte) {
|
||||
hb.buf = append(hb.buf, b...)
|
||||
}
|
||||
|
||||
func (hb *headerBuf) free() int { return cap(hb.buf) - len(hb.buf) }
|
||||
|
||||
func (hb *headerBuf) parseNextHeaders(ss *scannerState) {
|
||||
debuglog("http:nexthdr:loop")
|
||||
for kv := hb.next(ss); kv.isValid(); kv = hb.next(ss) {
|
||||
if len(hb.headers) == cap(hb.headers) {
|
||||
// Refuse to grow the headers slice: caller must pre-allocate
|
||||
// sufficient capacity via reset or use a larger initial size.
|
||||
ss.err = errOOM
|
||||
return
|
||||
}
|
||||
hb.headers = append(hb.headers, kv)
|
||||
}
|
||||
debuglog("http:nexthdr:done")
|
||||
}
|
||||
|
||||
func (hb *headerBuf) offBuf() []byte {
|
||||
return hb.buf[hb.off:]
|
||||
}
|
||||
|
||||
func (hb *headerBuf) skipLeadingCRLF() {
|
||||
for hb.off < len(hb.buf) && (hb.buf[hb.off] == '\n' || hb.buf[hb.off] == '\r') {
|
||||
hb.off++
|
||||
}
|
||||
}
|
||||
|
||||
func (hb *headerBuf) scanLine() []byte {
|
||||
buf := hb.scanUntilByte('\n')
|
||||
if len(buf) > 0 && buf[len(buf)-1] == '\r' {
|
||||
buf = buf[:len(buf)-1] // exclude carriage return.
|
||||
}
|
||||
if hb.off < len(hb.buf) {
|
||||
hb.off++ // consume newline.
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func (hb *headerBuf) scanUntilByte(c byte) []byte {
|
||||
buf := hb.offBuf()
|
||||
idx := bytes.IndexByte(buf, c)
|
||||
if idx >= 0 {
|
||||
buf = buf[:idx]
|
||||
}
|
||||
hb.off += len(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func (hb *headerBuf) parseFirstLineRequest(initFlags flags) (method, uri, proto headerSlice, flags flags, err error) {
|
||||
debuglog("http:req:scan")
|
||||
hb.off = 0 // Parsing first line resets offset.
|
||||
hb.skipLeadingCRLF()
|
||||
flags = initFlags
|
||||
if bytes.IndexByte(hb.offBuf(), '\n') < 0 {
|
||||
return method, uri, proto, flags, errNeedMore // Incomplete line.
|
||||
}
|
||||
b := hb.scanLine()
|
||||
if len(b) < 5 {
|
||||
return method, uri, proto, flags, errNeedMore
|
||||
}
|
||||
debuglog("http:req:parse")
|
||||
|
||||
methodEnd := max(0, bytes.IndexByte(b, ' '))
|
||||
reqURIEnd := bytes.IndexByte(b[methodEnd+1:], ' ')
|
||||
if reqURIEnd > 0 {
|
||||
reqURIEnd += methodEnd + 1
|
||||
uri = hb.slice(b[methodEnd+1 : reqURIEnd])
|
||||
proto = hb.slice(b[reqURIEnd+1:]) // Skip space before protocol.
|
||||
if b2s(b[reqURIEnd+1:]) != strHTTP11 {
|
||||
flags |= flagNoHTTP11
|
||||
}
|
||||
} else if reqURIEnd == 0 {
|
||||
return method, uri, proto, flags, errEmptyURI
|
||||
} else {
|
||||
// No version provided.
|
||||
flags |= flagNoHTTP11
|
||||
uri = hb.slice(b[methodEnd+1:])
|
||||
}
|
||||
method = hb.slice(b[:methodEnd])
|
||||
return method, uri, proto, flags, nil
|
||||
}
|
||||
|
||||
func (hb *headerBuf) parseFirstLineResponse(initFlags flags) (statusCode, statusText headerSlice, flags flags, err error) {
|
||||
debuglog("http:resp:scan")
|
||||
hb.off = 0 // Parsing first line resets offset.
|
||||
hb.skipLeadingCRLF()
|
||||
flags = initFlags
|
||||
if bytes.IndexByte(hb.offBuf(), '\n') < 0 {
|
||||
return statusCode, statusText, flags, errNeedMore // Incomplete line.
|
||||
}
|
||||
b := hb.scanLine()
|
||||
if len(b) < 5 {
|
||||
return statusCode, statusText, flags, errNeedMore
|
||||
}
|
||||
debuglog("http:resp:parse")
|
||||
|
||||
// Parse protocol (e.g. "HTTP/1.1"), then status code, then status text.
|
||||
protoEnd := bytes.IndexByte(b, ' ')
|
||||
if protoEnd < 0 {
|
||||
return statusCode, statusText, flags, errNeedMore
|
||||
}
|
||||
if b2s(b[:protoEnd]) != strHTTP11 {
|
||||
flags |= flagNoHTTP11
|
||||
}
|
||||
b = b[protoEnd+1:] // Advance past protocol and space.
|
||||
|
||||
codeEnd := bytes.IndexByte(b, ' ')
|
||||
if codeEnd < 0 {
|
||||
codeEnd = len(b) // Status text is optional.
|
||||
}
|
||||
code := b[:codeEnd]
|
||||
if len(code) > 3 {
|
||||
return statusCode, statusText, flags, errLongStatusCode
|
||||
}
|
||||
for i := range code {
|
||||
if code[i] > '9' || code[i] < '0' {
|
||||
debuglog("http:resp:invalid-code")
|
||||
return statusCode, statusText, flags, errBadStatusCode
|
||||
}
|
||||
}
|
||||
statusCode = hb.slice(code)
|
||||
if codeEnd < len(b) {
|
||||
statusText = hb.slice(b[codeEnd+1:]) // Skip space before text.
|
||||
}
|
||||
debuglog("http:resp:done")
|
||||
return statusCode, statusText, flags, nil
|
||||
}
|
||||
|
||||
func (kv argsKV) isValid() bool {
|
||||
return kv.key.start > 0
|
||||
}
|
||||
|
||||
func (kv *argsKV) invalidate() {
|
||||
*kv = argsKV{}
|
||||
}
|
||||
|
||||
func (tb headerBuf) musttoken(slice headerSlice) []byte {
|
||||
return tok2bytes(tb.buf, slice)
|
||||
|
||||
}
|
||||
|
||||
func (tb headerBuf) slice(b []byte) headerSlice {
|
||||
return bytes2tok(tb.buf, b)
|
||||
}
|
||||
|
||||
func (kv argsKV) HasValue() bool { return kv.value.start > 0 }
|
||||
|
||||
func (h *Header) hasHeaderValue(key, value string) bool {
|
||||
kv := h.peekHeader(key)
|
||||
return kv.isValid() && b2s(h.hbuf.musttoken(kv.value)) == value
|
||||
}
|
||||
|
||||
// peekHeader returns header key-value for the given key.
|
||||
//
|
||||
// The returned value is valid until the request is released,
|
||||
// either though ReleaseRequest or your request handler returning.
|
||||
// Do not store references to returned value. Make copies instead.
|
||||
func (h *Header) peekHeader(key string) argsKV {
|
||||
hb := &h.hbuf
|
||||
for i := 0; i < len(h.hbuf.headers); i++ {
|
||||
if b2s(hb.musttoken(h.hbuf.headers[i].key)) == key {
|
||||
return h.hbuf.headers[i]
|
||||
}
|
||||
}
|
||||
return hb.noKV()
|
||||
}
|
||||
|
||||
func (hb *headerBuf) mustAppendSlice(value string) headerSlice {
|
||||
L := len(hb.buf)
|
||||
if L == 0 {
|
||||
L++ // Valid key-values start after 0.
|
||||
}
|
||||
copy(hb.buf[L:L+len(value)], value)
|
||||
hb.buf = hb.buf[:L+len(value)]
|
||||
return hb.slice(hb.buf[L : L+len(value)])
|
||||
}
|
||||
|
||||
func (h *Header) reuseOrAppend(tok headerSlice, value string) headerSlice {
|
||||
if tok.len > tokint(len(value)) {
|
||||
copy(h.hbuf.musttoken(tok), value)
|
||||
tok.len = tokint(len(value))
|
||||
return tok
|
||||
}
|
||||
return h.appendSlice(value)
|
||||
}
|
||||
|
||||
func (h *Header) appendSlice(value string) headerSlice {
|
||||
debuglog("http:appendslice:start")
|
||||
free := h.hbuf.free()
|
||||
if len(value) > free {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
h.flags |= flagOOMReached
|
||||
return headerSlice{}
|
||||
}
|
||||
debuglog("http:appendslice:grow-buf")
|
||||
h.hbuf.buf = slices.Grow(h.hbuf.buf, len(value)+1) // Grow 1 beyond due to slice validity.
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
return h.hbuf.mustAppendSlice(value)
|
||||
}
|
||||
|
||||
func (h *Header) appendHeader(key, value string) {
|
||||
hb := &h.hbuf
|
||||
free := hb.free()
|
||||
buf := h.hbuf.buf
|
||||
|
||||
if len(key)+len(value) > free {
|
||||
if h.flags.hasAny(flagNoBufferGrow) {
|
||||
panic(errSmallBuffer)
|
||||
}
|
||||
debuglog("http:appendhdr:grow-buf")
|
||||
hb.buf = slices.Grow(buf, len(key)+len(value))
|
||||
}
|
||||
h.flags |= flagMangledBuffer
|
||||
k := hb.mustAppendSlice(key)
|
||||
v := hb.mustAppendSlice(value)
|
||||
debuglog("http:appendhdr:grow-hdrs")
|
||||
hb.headers = append(hb.headers, argsKV{
|
||||
key: k,
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
|
||||
func (hb *headerBuf) noKV() argsKV { return argsKV{} }
|
||||
|
||||
func (hb *headerBuf) next(ss *scannerState) argsKV {
|
||||
if !ss.initialized {
|
||||
ss.nextColon = -1
|
||||
ss.nextNewLine = -1
|
||||
}
|
||||
buf := hb.buf[hb.off:]
|
||||
blen := len(buf)
|
||||
if blen >= 2 && buf[0] == '\r' && buf[1] == '\n' {
|
||||
hb.off += 2
|
||||
return hb.noKV() // \r\n\r\n Ends header.
|
||||
} else if blen >= 1 && buf[0] == '\n' {
|
||||
hb.off += 1
|
||||
return hb.noKV() // \n\n Ends header.
|
||||
}
|
||||
|
||||
// n is parsing offset. Will start by storing colon index.
|
||||
n := 0
|
||||
if ss.nextColon >= 0 {
|
||||
// Retake from last colon found.
|
||||
n = ss.nextColon
|
||||
ss.nextColon = -1
|
||||
} else {
|
||||
n = bytes.IndexByte(buf, ':')
|
||||
x := bytes.IndexByte(buf, '\n')
|
||||
if x < 0 {
|
||||
// A header name should always at some point be followed by a \n
|
||||
// even if it's the one that terminates the header block.
|
||||
ss.err = errNeedMore
|
||||
return hb.noKV()
|
||||
} else if x < n {
|
||||
// There was a \n before the colon! This is invalid.
|
||||
ss.err = errInvalidName
|
||||
return hb.noKV()
|
||||
} else if n < 0 {
|
||||
// No colon found, probably missing data.
|
||||
ss.err = errNeedMore
|
||||
return hb.noKV()
|
||||
}
|
||||
}
|
||||
// n stores colon position by now.
|
||||
if bytes.IndexByte(buf[:n], ' ') >= 0 || bytes.IndexByte(buf[:n], '\t') >= 0 {
|
||||
// Spaces between the header key and colon are not allowed.
|
||||
// See RFC 7230, Section 3.2.4.
|
||||
ss.err = errInvalidName
|
||||
return hb.noKV()
|
||||
}
|
||||
|
||||
// Ready to store key..
|
||||
var resultKV argsKV
|
||||
resultKV.key = hb.slice(buf[:n])
|
||||
n++ // consume colon.
|
||||
for len(buf) > n && buf[n] == ' ' {
|
||||
n++ // Trim leading spaces.
|
||||
}
|
||||
// n now points to start of value.
|
||||
valueStart := n
|
||||
|
||||
// Find end of value. Values may be multiline, in which case we must treat newlines followed by whitespace as part of the value.
|
||||
for {
|
||||
nl := bytes.IndexByte(buf[n:], '\n')
|
||||
if nl < 0 || nl+n+1 == len(buf) {
|
||||
// No newline or newline is last character and can't know if is multiline.
|
||||
ss.err = errNeedMore
|
||||
return hb.noKV()
|
||||
}
|
||||
n += nl + 1 // Index of the newly found newline.
|
||||
nextChar := buf[n]
|
||||
if nextChar != ' ' && nextChar != '\t' {
|
||||
break // End of value found.
|
||||
}
|
||||
}
|
||||
|
||||
valueEnd := n - 1 // Trim newline.
|
||||
if valueEnd > valueStart && buf[valueEnd-1] == '\r' {
|
||||
valueEnd-- // Trim \r character if present before value.
|
||||
}
|
||||
resultKV.value = hb.slice(buf[valueStart:valueEnd])
|
||||
hb.off += n
|
||||
return resultKV
|
||||
}
|
||||
|
||||
// ConnectionClose returns true if 'Connection: close' header is set or if a invalid header was found.
|
||||
func (h *Header) ConnectionClose() bool {
|
||||
closed := h.flags.hasAny(flagConnClose) ||
|
||||
h.hasHeaderValue(headerConnection, strClose) ||
|
||||
(h.flags.hasAny(flagNoHTTP11) && !h.hasHeaderValue(headerConnection, "keep-alive"))
|
||||
if closed {
|
||||
h.flags |= flagConnClose
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
// b2s converts byte slice to a string without memory allocation.
|
||||
// See https://groups.google.com/forum/#!msg/Golang-Nuts/ENgbUzYvCuU/90yGx7GUAgAJ .
|
||||
func b2s(b []byte) string {
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
func tok2bytes(buf []byte, slice headerSlice) []byte {
|
||||
return buf[slice.start : slice.start+slice.len]
|
||||
}
|
||||
|
||||
func bytes2tok(buf, value []byte) headerSlice {
|
||||
base := uintptr(unsafe.Pointer(unsafe.SliceData(buf)))
|
||||
off := uintptr(unsafe.Pointer(unsafe.SliceData(value)))
|
||||
if off < base || off > base+uintptr(len(buf)) {
|
||||
panic("httpx: argument buffer does not alias header buffer")
|
||||
}
|
||||
return headerSlice{
|
||||
start: tokint(off - base),
|
||||
len: tokint(len(value)),
|
||||
}
|
||||
}
|
||||
|
||||
const enableDebug = internal.HeapAllocDebugging
|
||||
|
||||
func debuglog(msg string) {
|
||||
if enableDebug {
|
||||
internal.LogAllocs(msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoProto = errors.New("missing protocol, HTTP/0.9 unsupported")
|
||||
// ErrNeedMoreData signals a parser was handed an incomplete buffer: append
|
||||
// more data to it and call again.
|
||||
ErrNeedMoreData = errors.New("need more data: cannot find trailing lf/delimiter")
|
||||
errNoBoundary = errors.New("httpraw: multipart boundary not set")
|
||||
errUnparsed = errors.New("need to finish parsing")
|
||||
errInvalidName = errors.New("invalid header name")
|
||||
// ErrBufferExhausted signals a buffer with no room left for the data being
|
||||
// written and no permission to grow, see [KVBuffer.EnableBufferGrowth].
|
||||
// Enlarging the buffer handed to Reset is the only fix; a server answers it
|
||||
// on a request header with 431, RFC 6585 5.
|
||||
ErrBufferExhausted = errors.New("httpraw: buffer exhausted, increase size")
|
||||
// ErrHeaderTooMany signals a header block carrying more fields than
|
||||
// the buffer it is parsed into has room for, see [Header.Reset]. A server
|
||||
// answers it with 431, RFC 6585 5: no larger buffer is coming, so reading
|
||||
// the rest of the block would only spend memory on a request already lost.
|
||||
ErrHeaderTooMany = errors.New("httpraw: more header fields than buffer holds")
|
||||
// Header.Set and Header.Add mangles the buffer.
|
||||
// Call them after retrieving the Body. Do not call them before parsing the header (why would you even do that?).
|
||||
errMangledBuffer = errors.New("httpraw: mangled buffer")
|
||||
errNoCookies = errors.New("no cookie found")
|
||||
errEmptyURI = errors.New("empty URI")
|
||||
errLongStatusCode = errors.New("long status code")
|
||||
errBadStatusCode = errors.New("invalid status code")
|
||||
errAlreadyParsed = errors.New("TryParse called after header parsed")
|
||||
errNeedMethodURI = errors.New("need method/request URI to create request header")
|
||||
errBadStatusCodeTxt = errors.New("invalid status code or text")
|
||||
errCookiesParsed = errors.New("cookies already parsed, reset before parsing again")
|
||||
errBufferTooLarge = errors.New("httpraw: buffer exceeds max size (offsets are uint16)")
|
||||
errBadPercentEncode = errors.New("httpraw: invalid percent-encoding in URL")
|
||||
errBadDelimiter = errors.New("httpraw: junk between multipart delimiter and part")
|
||||
errNoContentLength = errors.New("httpraw: no Content-Length field")
|
||||
errBadContentLength = errors.New("httpraw: invalid Content-Length value")
|
||||
)
|
||||
|
||||
// maxBufLen bounds the header buffer. Offsets/lengths are stored as uint16
|
||||
// (tokint); a buffer past this would truncate/overflow those, silently
|
||||
// returning the wrong bytes or panicking on a wrapped slice bound.
|
||||
const maxBufLen = 0xffff
|
||||
|
||||
func protoIsV1(s string) bool {
|
||||
return s[:min(len(strHTTP1), len(s))] == strHTTP1
|
||||
}
|
||||
|
||||
type headerv1Buf struct {
|
||||
kv kvBuffer
|
||||
// buf[:len] holds entire HTTP header data, which may be normalized by [flags]. buf[off:len] holds data not yet processed during parsing.
|
||||
// buf []byte
|
||||
// offset into buf for parsing.
|
||||
off int
|
||||
// args contains key-value store.
|
||||
// headers []argsKV
|
||||
}
|
||||
|
||||
// reset sets the buffer data and discards all parsed data. The field table is
|
||||
// grown to match the new buffer's capacity and never shrinks, so a header
|
||||
// reused across requests settles on its largest buffer and stops allocating.
|
||||
func (h *headerv1Buf) reset(buf []byte, numHeaderCapacity int) {
|
||||
h.kv.Reset(buf, numHeaderCapacity)
|
||||
h.off = 0
|
||||
}
|
||||
|
||||
type scannerState struct {
|
||||
err error
|
||||
|
||||
// by checking whether the next line contains a colon or not to tell
|
||||
// it's a header entry or a multi line value of current header entry.
|
||||
// the side effect of this operation is that we know the index of the
|
||||
// next colon and new line, so this can be used during next iteration,
|
||||
// instead of find them again.
|
||||
nextColon int
|
||||
nextNewLine int
|
||||
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func (h *HeaderV1) parse(asResponse bool) (err error) {
|
||||
debuglog("http:firstline:start")
|
||||
err = h.parseFirstLine(asResponse)
|
||||
if err != nil {
|
||||
debuglog("http:firstline:err")
|
||||
return err
|
||||
}
|
||||
debuglog("http:firstline:done")
|
||||
err = h.parseNextHeaders(h.Flags())
|
||||
debuglog("http:headers:done")
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *HeaderV1) parseFirstLine(asResponse bool) (err error) {
|
||||
if len(h.hbuf.kv.buf) > maxBufLen {
|
||||
return errBufferTooLarge // Offsets would overflow uint16 tokint.
|
||||
}
|
||||
flags := h.Flags()
|
||||
if asResponse {
|
||||
h.statusCode, h.statusText, flags, err = h.hbuf.parseFirstLineResponse(flags)
|
||||
} else {
|
||||
h.method, h.requestTarget, h.proto, flags, err = h.hbuf.parseFirstLineRequest(flags)
|
||||
}
|
||||
h.hbuf.kv.flags = flags
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *HeaderV1) parseNextHeaders(flags Flags) error {
|
||||
var ss scannerState
|
||||
h.hbuf.parseNextHeaders(&ss, flags)
|
||||
if ss.err != nil {
|
||||
h.hbuf.kv.flags |= flagConnClose
|
||||
return ss.err
|
||||
}
|
||||
h.hbuf.kv.flags |= flagDoneParsingHeader
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) free() int { return hb.kv.free() }
|
||||
|
||||
func (hb *headerv1Buf) parseNextHeaders(ss *scannerState, flags Flags) {
|
||||
debuglog("http:nexthdr:loop")
|
||||
for kv := hb.next(ss); kv.isValidHeader(); kv = hb.next(ss) {
|
||||
if !hb.kv.canAddOneKV() {
|
||||
ss.err = ErrHeaderTooMany
|
||||
return
|
||||
}
|
||||
hb.kv.kvs = append(hb.kv.kvs, kv)
|
||||
}
|
||||
debuglog("http:nexthdr:done")
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) offBuf() []byte {
|
||||
return hb.kv.buf[hb.off:]
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) skipLeadingCRLF() {
|
||||
for hb.off < len(hb.kv.buf) && (hb.kv.buf[hb.off] == '\n' || hb.kv.buf[hb.off] == '\r') {
|
||||
hb.off++
|
||||
}
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) scanLine() []byte {
|
||||
buf := hb.scanUntilByte('\n')
|
||||
if len(buf) > 0 && buf[len(buf)-1] == '\r' {
|
||||
buf = buf[:len(buf)-1] // exclude carriage return.
|
||||
}
|
||||
if hb.off < len(hb.kv.buf) {
|
||||
hb.off++ // consume newline.
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) scanUntilByte(c byte) []byte {
|
||||
buf := hb.offBuf()
|
||||
idx := bytes.IndexByte(buf, c)
|
||||
if idx >= 0 {
|
||||
buf = buf[:idx]
|
||||
}
|
||||
hb.off += len(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) parseFirstLineRequest(initFlags Flags) (method, uri, proto view, flags Flags, err error) {
|
||||
debuglog("http:req:scan")
|
||||
hb.off = 0 // Parsing first line resets offset.
|
||||
hb.skipLeadingCRLF()
|
||||
flags = initFlags
|
||||
if bytes.IndexByte(hb.offBuf(), '\n') < 0 {
|
||||
return method, uri, proto, flags, ErrNeedMoreData // Incomplete line.
|
||||
}
|
||||
b := hb.scanLine()
|
||||
if len(b) < 5 {
|
||||
return method, uri, proto, flags, ErrNeedMoreData
|
||||
}
|
||||
debuglog("http:req:parse")
|
||||
|
||||
methodEnd := max(0, bytes.IndexByte(b, ' '))
|
||||
reqURIEnd := bytes.IndexByte(b[methodEnd+1:], ' ')
|
||||
if reqURIEnd > 0 {
|
||||
reqURIEnd += methodEnd + 1
|
||||
uri = hb.kv.view(b[methodEnd+1 : reqURIEnd])
|
||||
proto = hb.kv.view(b[reqURIEnd+1:]) // Skip space before protocol.
|
||||
protoText := b2s(b[reqURIEnd+1:])
|
||||
if !protoIsV1(protoText) {
|
||||
// Refused here rather than after the fields: the field loop is nearly
|
||||
// all of the parse cost and none of it serves a version this type
|
||||
// does not speak. proto is set so the caller can name it, i.e: 505.
|
||||
method = hb.kv.view(b[:methodEnd])
|
||||
return method, uri, proto, flags | flagNoHTTP11, lneto.ErrUnsupported
|
||||
} else if protoText != strHTTP11 {
|
||||
flags |= flagNoHTTP11 // HTTP/1.0, which defaults to closing the connection.
|
||||
}
|
||||
} else if reqURIEnd == 0 {
|
||||
return method, uri, proto, flags, errEmptyURI
|
||||
} else {
|
||||
// No version at all is a HTTP/0.9 simple-request, not a 1.x request-line,
|
||||
// RFC 9112 3. proto stays empty, telling it apart from a named version.
|
||||
uri = hb.kv.view(b[methodEnd+1:])
|
||||
method = hb.kv.view(b[:methodEnd])
|
||||
return method, uri, proto, flags | flagNoHTTP11, lneto.ErrUnsupported
|
||||
}
|
||||
method = hb.kv.view(b[:methodEnd])
|
||||
return method, uri, proto, flags, nil
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) parseFirstLineResponse(initFlags Flags) (statusCode, statusText view, flags Flags, err error) {
|
||||
debuglog("http:resp:scan")
|
||||
hb.off = 0 // Parsing first line resets offset.
|
||||
hb.skipLeadingCRLF()
|
||||
flags = initFlags
|
||||
if bytes.IndexByte(hb.offBuf(), '\n') < 0 {
|
||||
return statusCode, statusText, flags, ErrNeedMoreData // Incomplete line.
|
||||
}
|
||||
b := hb.scanLine()
|
||||
if len(b) < 5 {
|
||||
return statusCode, statusText, flags, ErrNeedMoreData
|
||||
}
|
||||
debuglog("http:resp:parse")
|
||||
|
||||
// Parse protocol (e.g. "HTTP/1.1"), then status code, then status text.
|
||||
protoEnd := bytes.IndexByte(b, ' ')
|
||||
if protoEnd < 0 {
|
||||
return statusCode, statusText, flags, ErrNeedMoreData
|
||||
}
|
||||
if !protoIsV1(b2s(b[:protoEnd])) {
|
||||
// Refused before the fields, as on the request side: a response naming
|
||||
// another version is not one this type can read.
|
||||
return statusCode, statusText, flags | flagNoHTTP11, lneto.ErrUnsupported
|
||||
} else if b2s(b[:protoEnd]) != strHTTP11 {
|
||||
flags |= flagNoHTTP11
|
||||
}
|
||||
b = b[protoEnd+1:] // Advance past protocol and space.
|
||||
|
||||
codeEnd := bytes.IndexByte(b, ' ')
|
||||
if codeEnd < 0 {
|
||||
codeEnd = len(b) // Status text is optional.
|
||||
}
|
||||
code := b[:codeEnd]
|
||||
if len(code) > 3 {
|
||||
return statusCode, statusText, flags, errLongStatusCode
|
||||
}
|
||||
for i := range code {
|
||||
if code[i] > '9' || code[i] < '0' {
|
||||
debuglog("http:resp:invalid-code")
|
||||
return statusCode, statusText, flags, errBadStatusCode
|
||||
}
|
||||
}
|
||||
statusCode = hb.kv.view(code)
|
||||
if codeEnd < len(b) {
|
||||
statusText = hb.kv.view(b[codeEnd+1:]) // Skip space before text.
|
||||
}
|
||||
debuglog("http:resp:done")
|
||||
return statusCode, statusText, flags, nil
|
||||
}
|
||||
|
||||
func (hb *headerv1Buf) next(ss *scannerState) pairKV {
|
||||
if !ss.initialized {
|
||||
ss.nextColon = -1
|
||||
ss.nextNewLine = -1
|
||||
}
|
||||
buf := hb.kv.buf[hb.off:]
|
||||
blen := len(buf)
|
||||
if blen >= 2 && buf[0] == '\r' && buf[1] == '\n' {
|
||||
hb.off += 2
|
||||
return hb.kv.noKV() // \r\n\r\n Ends header.
|
||||
} else if blen >= 1 && buf[0] == '\n' {
|
||||
hb.off += 1
|
||||
return hb.kv.noKV() // \n\n Ends header.
|
||||
}
|
||||
|
||||
// n is parsing offset. Will start by storing colon index.
|
||||
n := 0
|
||||
if ss.nextColon >= 0 {
|
||||
// Retake from last colon found.
|
||||
n = ss.nextColon
|
||||
ss.nextColon = -1
|
||||
} else {
|
||||
n = bytes.IndexByte(buf, ':')
|
||||
x := bytes.IndexByte(buf, '\n')
|
||||
if x < 0 {
|
||||
// A header name should always at some point be followed by a \n
|
||||
// even if it's the one that terminates the header block.
|
||||
ss.err = ErrNeedMoreData
|
||||
return hb.kv.noKV()
|
||||
} else if x < n {
|
||||
// There was a \n before the colon! This is invalid.
|
||||
ss.err = errInvalidName
|
||||
return hb.kv.noKV()
|
||||
} else if n < 0 {
|
||||
// A newline is present (x>=0 reached here) but the line has no
|
||||
// colon: malformed, not incomplete. A split arriving before the
|
||||
// colon has no newline yet and is caught by the x<0 branch above,
|
||||
// so it still returns ErrNeedMoreData.
|
||||
ss.err = errInvalidName
|
||||
return hb.kv.noKV()
|
||||
}
|
||||
}
|
||||
// n stores colon position by now.
|
||||
if bytes.IndexByte(buf[:n], ' ') >= 0 || bytes.IndexByte(buf[:n], '\t') >= 0 {
|
||||
// Spaces between the header key and colon are not allowed.
|
||||
// See RFC 7230, Section 3.2.4.
|
||||
ss.err = errInvalidName
|
||||
return hb.kv.noKV()
|
||||
}
|
||||
|
||||
// Ready to store key..
|
||||
var resultKV pairKV
|
||||
resultKV.key = hb.kv.view(buf[:n])
|
||||
n++ // consume colon.
|
||||
for len(buf) > n && buf[n] == ' ' {
|
||||
n++ // Trim leading spaces.
|
||||
}
|
||||
// n now points to start of value.
|
||||
valueStart := n
|
||||
|
||||
// Find end of value. Values may be multiline, in which case we must treat newlines followed by whitespace as part of the value.
|
||||
for {
|
||||
nl := bytes.IndexByte(buf[n:], '\n')
|
||||
if nl < 0 || nl+n+1 == len(buf) {
|
||||
// No newline or newline is last character and can't know if is multiline.
|
||||
ss.err = ErrNeedMoreData
|
||||
return hb.kv.noKV()
|
||||
}
|
||||
n += nl + 1 // Index of the newly found newline.
|
||||
nextChar := buf[n]
|
||||
if nextChar != ' ' && nextChar != '\t' {
|
||||
break // End of value found.
|
||||
}
|
||||
}
|
||||
|
||||
valueEnd := n - 1 // Trim newline.
|
||||
if valueEnd > valueStart && buf[valueEnd-1] == '\r' {
|
||||
valueEnd-- // Trim \r character if present before value.
|
||||
}
|
||||
resultKV.value = hb.kv.view(buf[valueStart:valueEnd])
|
||||
hb.off += n
|
||||
return resultKV
|
||||
}
|
||||
|
||||
// ConnectionClose returns true if 'Connection: close' header is set or if a invalid header was found.
|
||||
func (h *HeaderV1) ConnectionClose() bool {
|
||||
flags := h.Flags()
|
||||
closed := flags.HasAny(flagConnClose) ||
|
||||
h.hasConnectionToken(strClose) ||
|
||||
(flags.HasAny(flagNoHTTP11) && !h.hasConnectionToken(strKeepAlive))
|
||||
if closed {
|
||||
h.hbuf.kv.flags |= flagConnClose
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
// hasConnectionToken reports whether the Connection field lists token, which
|
||||
// must be lowercase. The field name, its comma list and each token all compare
|
||||
// case insensitively, RFC 9110 5.1 and 7.6.1.
|
||||
func (h *HeaderV1) hasConnectionToken(token string) bool {
|
||||
value := h.GetFold(headerConnection)
|
||||
for len(value) > 0 {
|
||||
item := value
|
||||
if comma := bytes.IndexByte(value, ','); comma >= 0 {
|
||||
item, value = value[:comma], value[comma+1:]
|
||||
} else {
|
||||
value = nil
|
||||
}
|
||||
if equalFold(trimOWS(item), token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const enableDebug = internal.HeapAllocDebugging
|
||||
|
||||
func debuglog(msg string) {
|
||||
if enableDebug {
|
||||
internal.LogAllocs(msg)
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,28 @@ package httpraw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
func TestTryParse_IncrementalRequest(t *testing.T) {
|
||||
// Full HTTP request split across multiple ReadFromBytes calls.
|
||||
full := "GET /index.html HTTP/1.1\r\nHost: example.com\r\nContent-Type: text/html\r\n\r\nbody here"
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
|
||||
// Feed data in small chunks to exercise incremental parsing.
|
||||
chunks := splitInto(full, 10)
|
||||
var done bool
|
||||
var doneIdx int
|
||||
for i, chunk := range chunks {
|
||||
n, err := hdr.ReadFromBytes([]byte(chunk))
|
||||
err := hdr.ReadFromBytes([]byte(chunk))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFromBytes: %v", err)
|
||||
}
|
||||
if n != len(chunk) {
|
||||
t.Fatalf("expected %d bytes read, got %d", len(chunk), n)
|
||||
}
|
||||
|
||||
var needMore bool
|
||||
needMore, err = hdr.TryParse(false)
|
||||
@@ -52,19 +52,16 @@ func TestTryParse_IncrementalRequest(t *testing.T) {
|
||||
if string(hdr.Method()) != "GET" {
|
||||
t.Errorf("method = %q; want GET", hdr.Method())
|
||||
}
|
||||
if string(hdr.RequestURI()) != "/index.html" {
|
||||
t.Errorf("URI = %q; want /index.html", hdr.RequestURI())
|
||||
if string(hdr.RequestTarget()) != "/index.html" {
|
||||
t.Errorf("URI = %q; want /index.html", hdr.RequestTarget())
|
||||
}
|
||||
|
||||
// Verify headers via ForEach.
|
||||
headers := make(map[string]string)
|
||||
err := hdr.ForEach(func(key, value []byte) error {
|
||||
hdr.ForEach(func(key, value []byte) bool {
|
||||
headers[string(key)] = string(value)
|
||||
return nil
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if headers["Host"] != "example.com" {
|
||||
t.Errorf("Host = %q; want example.com", headers["Host"])
|
||||
}
|
||||
@@ -84,8 +81,8 @@ func TestTryParse_IncrementalRequest(t *testing.T) {
|
||||
|
||||
func TestTryParse_IncrementalResponse(t *testing.T) {
|
||||
full := "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nServer: lneto\r\n\r\nhello"
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
|
||||
chunks := splitInto(full, 8)
|
||||
var done bool
|
||||
@@ -136,8 +133,8 @@ func TestReadFromLimited(t *testing.T) {
|
||||
data := "GET / HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
r := strings.NewReader(data)
|
||||
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
|
||||
// Read in one shot.
|
||||
n, err := hdr.ReadFromLimited(r, 256)
|
||||
@@ -162,8 +159,8 @@ func TestReadFromLimited(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadFromLimited_MaxBytes(t *testing.T) {
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
|
||||
// Zero maxBytesToRead should error.
|
||||
_, err := hdr.ReadFromLimited(strings.NewReader("data"), 0)
|
||||
@@ -173,18 +170,18 @@ func TestReadFromLimited_MaxBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadFromBytes_Empty(t *testing.T) {
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
|
||||
_, err := hdr.ReadFromBytes(nil)
|
||||
err := hdr.ReadFromBytes(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferFreeAndCapacity(t *testing.T) {
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 100))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 100), defaultKVCap)
|
||||
|
||||
if hdr.BufferCapacity() != 100 {
|
||||
t.Errorf("capacity = %d; want 100", hdr.BufferCapacity())
|
||||
@@ -200,17 +197,16 @@ func TestBufferFreeAndCapacity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnableBufferGrowth(t *testing.T) {
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
buf := make([]byte, 0, 64)
|
||||
hdr.Reset(buf)
|
||||
hdr.EnableBufferGrowth(false)
|
||||
|
||||
hdr.Reset(buf, defaultKVCap)
|
||||
hdr.ConfigBufferGrowth(false)
|
||||
// With growth disabled, reading more than capacity should fail.
|
||||
big := make([]byte, 128)
|
||||
for i := range big {
|
||||
big[i] = 'A'
|
||||
}
|
||||
_, err := hdr.ReadFromBytes(big)
|
||||
err := hdr.ReadFromBytes(big)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when buffer growth disabled and data exceeds capacity")
|
||||
}
|
||||
@@ -218,7 +214,7 @@ func TestEnableBufferGrowth(t *testing.T) {
|
||||
|
||||
func TestHeader_Add(t *testing.T) {
|
||||
full := "GET / HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(false, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -229,11 +225,11 @@ func TestHeader_Add(t *testing.T) {
|
||||
|
||||
// ForEach should find both.
|
||||
var values []string
|
||||
hdr.ForEach(func(key, value []byte) error {
|
||||
hdr.ForEach(func(key, value []byte) bool {
|
||||
if string(key) == "X-Custom" {
|
||||
values = append(values, string(value))
|
||||
}
|
||||
return nil
|
||||
return true
|
||||
})
|
||||
if len(values) != 2 {
|
||||
t.Fatalf("expected 2 X-Custom headers, got %d", len(values))
|
||||
@@ -245,7 +241,7 @@ func TestHeader_Add(t *testing.T) {
|
||||
|
||||
func TestHeader_SetBytes(t *testing.T) {
|
||||
full := "GET / HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(false, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -261,7 +257,7 @@ func TestHeader_SetBytes(t *testing.T) {
|
||||
func TestConnectionClose(t *testing.T) {
|
||||
t.Run("HTTP11_NoConnectionHeader", func(t *testing.T) {
|
||||
full := "GET / HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
hdr.ParseBytes(false, []byte(full))
|
||||
if hdr.ConnectionClose() {
|
||||
t.Error("HTTP/1.1 without Connection:close should not close")
|
||||
@@ -270,7 +266,7 @@ func TestConnectionClose(t *testing.T) {
|
||||
|
||||
t.Run("ExplicitClose", func(t *testing.T) {
|
||||
full := "GET / HTTP/1.1\r\nConnection: close\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
hdr.ParseBytes(false, []byte(full))
|
||||
if !hdr.ConnectionClose() {
|
||||
t.Error("Connection:close header should trigger close")
|
||||
@@ -279,7 +275,7 @@ func TestConnectionClose(t *testing.T) {
|
||||
|
||||
t.Run("HTTP10_NoKeepAlive", func(t *testing.T) {
|
||||
full := "GET / HTTP/1.0\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
hdr.ParseBytes(false, []byte(full))
|
||||
if !hdr.ConnectionClose() {
|
||||
t.Error("HTTP/1.0 without keep-alive should close")
|
||||
@@ -288,7 +284,7 @@ func TestConnectionClose(t *testing.T) {
|
||||
|
||||
t.Run("HTTP10_KeepAlive", func(t *testing.T) {
|
||||
full := "GET / HTTP/1.0\r\nConnection: keep-alive\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
hdr.ParseBytes(false, []byte(full))
|
||||
if hdr.ConnectionClose() {
|
||||
t.Error("HTTP/1.0 with keep-alive should not close")
|
||||
@@ -298,7 +294,7 @@ func TestConnectionClose(t *testing.T) {
|
||||
|
||||
func TestTryParse_AlreadyParsed(t *testing.T) {
|
||||
full := "GET / HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
hdr.ParseBytes(false, []byte(full))
|
||||
|
||||
// Calling TryParse again should return error.
|
||||
@@ -310,7 +306,7 @@ func TestTryParse_AlreadyParsed(t *testing.T) {
|
||||
|
||||
func TestParseResponse_BadStatusCode(t *testing.T) {
|
||||
full := "HTTP/1.1 abc Bad\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(true, []byte(full))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric status code")
|
||||
@@ -332,6 +328,14 @@ func TestCookie_ParseBytes(t *testing.T) {
|
||||
if string(c.Get("Path")) != "/" {
|
||||
t.Errorf("Path = %q; want /", c.Get("Path"))
|
||||
}
|
||||
// The first pair sits at buffer offset 0, which a presence check keyed on
|
||||
// the offset rather than the length reads as absent.
|
||||
if string(c.Get("session")) != "abc123" {
|
||||
t.Errorf("Get(session) = %q; want abc123", c.Get("session"))
|
||||
}
|
||||
if !c.HasKeyOrSingleValue("session") {
|
||||
t.Error("expected first pair to be present by key")
|
||||
}
|
||||
if !c.HasKeyOrSingleValue("Secure") {
|
||||
t.Error("expected Secure flag")
|
||||
}
|
||||
@@ -357,13 +361,10 @@ func TestCookie_ForEach(t *testing.T) {
|
||||
c.ParseBytes([]byte("a=1; b=2; c=3"))
|
||||
|
||||
var keys []string
|
||||
err := c.ForEach(func(key, value []byte) error {
|
||||
c.ForEach(func(key, value []byte) bool {
|
||||
keys = append(keys, string(key))
|
||||
return nil
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 3 {
|
||||
t.Fatalf("expected 3 cookie entries, got %d", len(keys))
|
||||
}
|
||||
@@ -372,7 +373,7 @@ func TestCookie_ForEach(t *testing.T) {
|
||||
func TestHeader_MultilineValue(t *testing.T) {
|
||||
// RFC 7230: obsolete line folding with \r\n followed by space/tab.
|
||||
full := "GET / HTTP/1.1\r\nX-Multi: line1\r\n\tline2\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(false, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -388,8 +389,8 @@ func TestHeader_MultilineValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeader_ResponseRoundTrip(t *testing.T) {
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
hdr.SetStatus("404", "Not Found")
|
||||
hdr.Add("Content-Type", "text/plain")
|
||||
@@ -413,7 +414,7 @@ func TestHeader_ResponseRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// Parse back the generated response.
|
||||
var hdr2 Header
|
||||
var hdr2 HeaderV1
|
||||
err = hdr2.ParseBytes(true, buf)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse response: %v", err)
|
||||
@@ -428,11 +429,11 @@ func TestHeader_ResponseRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeader_RequestRoundTrip(t *testing.T) {
|
||||
var hdr Header
|
||||
hdr.Reset(make([]byte, 0, 256))
|
||||
var hdr HeaderV1
|
||||
hdr.Reset(make([]byte, 0, 256), defaultKVCap)
|
||||
hdr.SetProtocol("HTTP/1.1")
|
||||
hdr.SetMethod("POST")
|
||||
hdr.SetRequestURI("/api/data")
|
||||
hdr.SetRequestTarget("/api/data")
|
||||
hdr.Add("Host", "example.com")
|
||||
hdr.Add("Content-Type", "application/json")
|
||||
|
||||
@@ -446,7 +447,7 @@ func TestHeader_RequestRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// Parse back the generated request.
|
||||
var hdr2 Header
|
||||
var hdr2 HeaderV1
|
||||
err = hdr2.ParseBytes(false, buf)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse request: %v", err)
|
||||
@@ -454,8 +455,8 @@ func TestHeader_RequestRoundTrip(t *testing.T) {
|
||||
if string(hdr2.Method()) != "POST" {
|
||||
t.Errorf("re-parsed method = %q; want POST", hdr2.Method())
|
||||
}
|
||||
if string(hdr2.RequestURI()) != "/api/data" {
|
||||
t.Errorf("re-parsed URI = %q; want /api/data", hdr2.RequestURI())
|
||||
if string(hdr2.RequestTarget()) != "/api/data" {
|
||||
t.Errorf("re-parsed URI = %q; want /api/data", hdr2.RequestTarget())
|
||||
}
|
||||
if string(hdr2.Get("Host")) != "example.com" {
|
||||
t.Errorf("re-parsed Host = %q; want example.com", hdr2.Get("Host"))
|
||||
@@ -465,7 +466,7 @@ func TestHeader_RequestRoundTrip(t *testing.T) {
|
||||
func TestParseResponse_StatusCodeOnly(t *testing.T) {
|
||||
// Response with status code but no status text.
|
||||
full := "HTTP/1.1 204\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(true, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -479,7 +480,7 @@ func TestParseResponse_StatusCodeOnly(t *testing.T) {
|
||||
|
||||
func TestParseResponse_HTTP10(t *testing.T) {
|
||||
full := "HTTP/1.0 200 OK\r\nServer: old\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(true, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -494,19 +495,23 @@ func TestParseResponse_HTTP10(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseRequest_NoProtocol(t *testing.T) {
|
||||
// HTTP/0.9 style: just method and URI, no version.
|
||||
// HTTP/0.9 style: just method and URI, no version. Refused, but the
|
||||
// request-line it did read stays readable so a caller can answer 400 and say
|
||||
// what it saw.
|
||||
full := "GET /simple\r\nHost: test\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(false, []byte(full))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if !errors.Is(err, lneto.ErrUnsupported) {
|
||||
t.Fatalf("want lneto.ErrUnsupported for a version-less request, got %v", err)
|
||||
}
|
||||
if string(hdr.Method()) != "GET" {
|
||||
t.Errorf("method = %q; want GET", hdr.Method())
|
||||
}
|
||||
if string(hdr.RequestURI()) != "/simple" {
|
||||
t.Errorf("URI = %q; want /simple", hdr.RequestURI())
|
||||
if string(hdr.RequestTarget()) != "/simple" {
|
||||
t.Errorf("URI = %q; want /simple", hdr.RequestTarget())
|
||||
}
|
||||
// An empty protocol is what tells HTTP/0.9 apart from a named version, which
|
||||
// is how [httphi] picks 400 over 505.
|
||||
if hdr.Protocol() != nil {
|
||||
t.Errorf("protocol should be nil for version-less request, got %q", hdr.Protocol())
|
||||
}
|
||||
@@ -523,7 +528,7 @@ func TestCookie_QuotedValue(t *testing.T) {
|
||||
func TestParseRequest_InvalidHeaderSpaceBeforeColon(t *testing.T) {
|
||||
// RFC 7230 §3.2.4: No whitespace allowed between header name and colon.
|
||||
full := "GET / HTTP/1.1\r\nBad Header : value\r\n\r\n"
|
||||
var hdr Header
|
||||
var hdr HeaderV1
|
||||
err := hdr.ParseBytes(false, []byte(full))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for space before colon in header name")
|
||||
@@ -539,3 +544,104 @@ func splitInto(s string, n int) []string {
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// Connection is a case-insensitive list of case-insensitive tokens, RFC 9110
|
||||
// 7.6.1, and its field name folds like any other, RFC 9110 5.1. Missing a close
|
||||
// token keeps serving a peer that asked to hang up; missing keep-alive hangs up
|
||||
// on an HTTP/1.0 peer that asked to stay.
|
||||
func TestConnectionCloseFolded(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
proto string
|
||||
field string
|
||||
wantClose bool
|
||||
}{
|
||||
{proto: "HTTP/1.1", field: "Connection: close", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "connection: close", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "CONNECTION: close", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "Connection: Close", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "Connection: CLOSE", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "Connection: keep-alive, close", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "Connection: close, keep-alive", wantClose: true},
|
||||
{proto: "HTTP/1.1", field: "Connection: TE, Close", wantClose: true},
|
||||
// A token that merely contains "close" is not the close token.
|
||||
{proto: "HTTP/1.1", field: "Connection: closed", wantClose: false},
|
||||
{proto: "HTTP/1.1", field: "Connection: keep-alive", wantClose: false},
|
||||
// HTTP/1.0 closes unless the peer asks to keep the connection.
|
||||
{proto: "HTTP/1.0", field: "Connection: keep-alive", wantClose: false},
|
||||
{proto: "HTTP/1.0", field: "connection: keep-alive", wantClose: false},
|
||||
{proto: "HTTP/1.0", field: "Connection: Keep-Alive", wantClose: false},
|
||||
{proto: "HTTP/1.0", field: "Connection: TE, keep-alive", wantClose: false},
|
||||
{proto: "HTTP/1.0", field: "Host: h", wantClose: true},
|
||||
} {
|
||||
t.Run(test.proto+" "+test.field, func(t *testing.T) {
|
||||
var hdr HeaderV1
|
||||
full := "GET / " + test.proto + "\r\nHost: h\r\n" + test.field + "\r\n\r\n"
|
||||
if err := hdr.ParseBytes(false, []byte(full)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hdr.ConnectionClose(); got != test.wantClose {
|
||||
t.Errorf("want ConnectionClose=%v, got %v", test.wantClose, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HeaderV1 speaks HTTP/1.x and nothing else, so a request or response naming
|
||||
// another version is refused on the first line. That skips the field loop, which
|
||||
// is where nearly all the parse cost is, and refuses the h2c preface a modern
|
||||
// client opens with before it is mistaken for a request.
|
||||
func TestHeaderV1RejectsUnsupportedVersion(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
raw string
|
||||
asResponse bool
|
||||
}{
|
||||
{name: "request http2", raw: "GET / HTTP/2.0\r\nHost: h\r\n\r\n"},
|
||||
{name: "request http3", raw: "GET / HTTP/3.0\r\nHost: h\r\n\r\n"},
|
||||
{name: "h2c preface", raw: "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"},
|
||||
{name: "request http09 no version", raw: "GET /index.html\r\nHost: h\r\n\r\n"},
|
||||
{name: "request bogus proto", raw: "GET / BANANA\r\nHost: h\r\n\r\n"},
|
||||
{name: "response http2", raw: "HTTP/2.0 200 OK\r\nServer: s\r\n\r\n", asResponse: true},
|
||||
{name: "response http09", raw: "HTTP/0.9 200 OK\r\nServer: s\r\n\r\n", asResponse: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var h HeaderV1
|
||||
err := h.ParseBytes(test.asResponse, []byte(test.raw))
|
||||
if !errors.Is(err, lneto.ErrUnsupported) {
|
||||
t.Fatalf("want lneto.ErrUnsupported, got %v", err)
|
||||
}
|
||||
// The field loop must not have run: refusing early is the point.
|
||||
fields := 0
|
||||
h.ForEach(func(key, value []byte) bool { fields++; return true })
|
||||
if fields != 0 {
|
||||
t.Errorf("want no fields parsed, got %d", fields)
|
||||
}
|
||||
if h.ParsingSuccess() {
|
||||
t.Error("a refused header must not report a successful parse")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Both HTTP/1 versions stay supported: only non-1.x is refused.
|
||||
func TestHeaderV1AcceptsV1Versions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
raw string
|
||||
asResponse bool
|
||||
}{
|
||||
{raw: "GET / HTTP/1.1\r\nHost: h\r\n\r\n"},
|
||||
{raw: "GET / HTTP/1.0\r\nHost: h\r\n\r\n"},
|
||||
{raw: "HTTP/1.1 200 OK\r\nServer: s\r\n\r\n", asResponse: true},
|
||||
{raw: "HTTP/1.0 200 OK\r\nServer: s\r\n\r\n", asResponse: true},
|
||||
} {
|
||||
t.Run(test.raw[:12], func(t *testing.T) {
|
||||
var h HeaderV1
|
||||
if err := h.ParseBytes(test.asResponse, []byte(test.raw)); err != nil {
|
||||
t.Fatalf("want parsed, got %v", err)
|
||||
}
|
||||
if !h.ParsingSuccess() {
|
||||
t.Error("want a successful parse")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Command benchci parses `go test -bench` output and renders a Markdown
|
||||
// report. It is repo-owned tooling so CI does not depend on third-party
|
||||
// benchmark actions. With -count>1 it reports the median of each metric to
|
||||
// reduce noise.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commentMarker is a stable HTML marker so a PR commenter can locate and
|
||||
// update an existing report comment instead of posting duplicates.
|
||||
const commentMarker = "<!-- lneto-bench -->"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
currentPath = flag.String("current", "", "path to `go test -bench` output (default stdin)")
|
||||
outPath = flag.String("out", "", "path to write Markdown report (default stdout)")
|
||||
title = flag.String("title", "Benchmark results", "report heading")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
in := io.Reader(os.Stdin)
|
||||
if *currentPath != "" {
|
||||
f, err := os.Open(*currentPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
|
||||
results, err := parse(in)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
out := io.Writer(os.Stdout)
|
||||
if *outPath != "" {
|
||||
f, err := os.Create(*outPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
out = f
|
||||
}
|
||||
|
||||
if err := render(out, *title, results); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "benchci:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// result is the aggregated metrics for a single benchmark.
|
||||
type result struct {
|
||||
pkg string
|
||||
name string // benchmark name including the GOMAXPROCS suffix, e.g. BenchmarkFoo-12
|
||||
|
||||
nsPerOp []float64
|
||||
bytesPerOp []float64
|
||||
allocsPerOp []float64
|
||||
}
|
||||
|
||||
// parse reads `go test -bench -benchmem` output and groups metric samples by
|
||||
// package and benchmark name. Repeated lines (from -count) accumulate samples.
|
||||
func parse(r io.Reader) ([]result, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
byKey := make(map[string]*result)
|
||||
var order []string
|
||||
var pkg string
|
||||
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "pkg:" && len(fields) >= 2 {
|
||||
pkg = fields[1]
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(fields[0], "Benchmark") || len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
// fields: name iters value unit [value unit]...
|
||||
name := fields[0]
|
||||
if _, err := strconv.Atoi(fields[1]); err != nil {
|
||||
continue // second field must be the iteration count
|
||||
}
|
||||
|
||||
key := pkg + "\x00" + name
|
||||
res := byKey[key]
|
||||
if res == nil {
|
||||
res = &result{pkg: pkg, name: name}
|
||||
byKey[key] = res
|
||||
order = append(order, key)
|
||||
}
|
||||
parseMetrics(res, fields[2:])
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]result, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, *byKey[k])
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].pkg != out[j].pkg {
|
||||
return out[i].pkg < out[j].pkg
|
||||
}
|
||||
return out[i].name < out[j].name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMetrics consumes (value, unit) pairs and appends the metrics benchci
|
||||
// reports on. Unknown metrics are ignored.
|
||||
func parseMetrics(res *result, tokens []string) {
|
||||
for i := 0; i+1 < len(tokens); i += 2 {
|
||||
v, err := strconv.ParseFloat(tokens[i], 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch tokens[i+1] {
|
||||
case "ns/op":
|
||||
res.nsPerOp = append(res.nsPerOp, v)
|
||||
case "B/op":
|
||||
res.bytesPerOp = append(res.bytesPerOp, v)
|
||||
case "allocs/op":
|
||||
res.allocsPerOp = append(res.allocsPerOp, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// median returns the median of samples. ok is false when there are no samples.
|
||||
func median(samples []float64) (value float64, ok bool) {
|
||||
if len(samples) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
s := append([]float64(nil), samples...)
|
||||
sort.Float64s(s)
|
||||
n := len(s)
|
||||
if n%2 == 1 {
|
||||
return s[n/2], true
|
||||
}
|
||||
return (s[n/2-1] + s[n/2]) / 2, true
|
||||
}
|
||||
|
||||
func render(w io.Writer, title string, results []result) error {
|
||||
bw := bufio.NewWriter(w)
|
||||
fmt.Fprintf(bw, "%s\n\n", commentMarker)
|
||||
fmt.Fprintf(bw, "### %s\n\n", title)
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(bw, "_No benchmarks found._")
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
fmt.Fprintln(bw, "_Timing results (`ns/op`) depend on the host CPU and are only a rough guideline. Memory results (`B/op` and `allocs/op`) are not affected._")
|
||||
fmt.Fprintln(bw)
|
||||
fmt.Fprintln(bw, "| Package | Benchmark | ns/op | B/op | allocs/op |")
|
||||
fmt.Fprintln(bw, "|---|---|---:|---:|---:|")
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(bw, "| %s | %s | %s | %s | %s |\n",
|
||||
shortPkg(r.pkg), r.name,
|
||||
formatNs(median(r.nsPerOp)),
|
||||
formatCount(median(r.bytesPerOp)),
|
||||
formatCount(median(r.allocsPerOp)),
|
||||
)
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// shortPkg trims the well-known module prefix for readability.
|
||||
func shortPkg(pkg string) string {
|
||||
const prefix = "github.com/soypat/lneto/"
|
||||
if pkg == "" {
|
||||
return "-"
|
||||
}
|
||||
return strings.TrimPrefix(pkg, prefix)
|
||||
}
|
||||
|
||||
func formatCount(v float64, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// formatNs renders a ns/op value using a human-friendly time unit.
|
||||
func formatNs(v float64, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
switch {
|
||||
case v >= 1e9:
|
||||
return fmt.Sprintf("%.3f s", v/1e9)
|
||||
case v >= 1e6:
|
||||
return fmt.Sprintf("%.3f ms", v/1e6)
|
||||
case v >= 1e3:
|
||||
return fmt.Sprintf("%.3f µs", v/1e3)
|
||||
default:
|
||||
return fmt.Sprintf("%.3f ns", v)
|
||||
}
|
||||
}
|
||||
+29
-14
@@ -2,16 +2,16 @@ package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
LevelTrace slog.Level = slog.LevelDebug - 2
|
||||
|
||||
usePrintLogAllocs = true
|
||||
usePrintLogAllocs = HeapAllocDebugging
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -19,9 +19,13 @@ var (
|
||||
lastAllocs uint64
|
||||
lastMallocs uint64
|
||||
allocmu sync.Mutex
|
||||
allocbuf [256]byte
|
||||
allocbuf [128]byte
|
||||
)
|
||||
|
||||
func LogAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
logAttrsAndAllocs(allocmsg, l, level, msg, attrs...)
|
||||
}
|
||||
|
||||
func LogAllocs(msg string) {
|
||||
allocmu.Lock()
|
||||
runtime.ReadMemStats(&memstats)
|
||||
@@ -29,23 +33,34 @@ func LogAllocs(msg string) {
|
||||
allocmu.Unlock()
|
||||
return
|
||||
}
|
||||
inc := int64(memstats.TotalAlloc) - int64(lastAllocs)
|
||||
numAlloc := int64(memstats.Mallocs) - int64(lastMallocs)
|
||||
free := memstats.HeapSys - memstats.HeapInuse
|
||||
if usePrintLogAllocs {
|
||||
// Branch used when debugheaplog enabled
|
||||
print("[ALLOC] ", msg)
|
||||
print(" inc=", int64(memstats.TotalAlloc)-int64(lastAllocs))
|
||||
print(" n=", int64(memstats.Mallocs)-int64(lastMallocs))
|
||||
print(" inc=", inc)
|
||||
print(" n=", numAlloc)
|
||||
print(" heap=", memstats.HeapAlloc)
|
||||
print(" free=", memstats.HeapSys-memstats.HeapInuse)
|
||||
print(" free=", free)
|
||||
print(" tot=", memstats.TotalAlloc)
|
||||
println()
|
||||
} else {
|
||||
n := copy(allocbuf[:], "[ALLOC] ")
|
||||
n += copy(allocbuf[n:], msg)
|
||||
n += copyValueInt(allocbuf[n:], "inc", int64(memstats.TotalAlloc)-int64(lastAllocs))
|
||||
n += copyValueInt(allocbuf[n:], "n", int64(memstats.Mallocs)-int64(lastMallocs))
|
||||
n += copyValueUint(allocbuf[n:], "heap", memstats.HeapAlloc)
|
||||
n += copyValueUint(allocbuf[n:], "free", memstats.HeapSys-memstats.HeapInuse)
|
||||
n += copyValueUint(allocbuf[n:], "tot", memstats.TotalAlloc)
|
||||
println(unsafe.String(&allocbuf[0], n))
|
||||
buf := allocbuf[:len(allocbuf)-1] // keep one character for newline.
|
||||
n := copy(buf[:], "[ALLOC] ")
|
||||
n += copy(buf[n:], msg)
|
||||
n += copyValueInt(buf[n:], "inc", inc)
|
||||
n += copyValueInt(buf[n:], "n", numAlloc)
|
||||
n += copyValueUint(buf[n:], "heap", memstats.HeapAlloc)
|
||||
n += copyValueUint(buf[n:], "free", free)
|
||||
lastN := copyValueUint(buf[n:], "tot", memstats.TotalAlloc)
|
||||
n += lastN
|
||||
allocbuf[n] = '\n' // n will never be larger than len(allocbuf)-1
|
||||
os.Stdout.Write(allocbuf[:n+1])
|
||||
if lastN == 0 {
|
||||
n2 := copy(allocbuf[:], "[WARN] ALLOC BUF OVERRUN\n")
|
||||
os.Stdout.Write(allocbuf[:n2])
|
||||
}
|
||||
}
|
||||
lastAllocs = memstats.TotalAlloc
|
||||
lastMallocs = memstats.Mallocs
|
||||
|
||||
@@ -4,7 +4,6 @@ package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -22,10 +21,13 @@ func LogEnabled(l *slog.Logger, lvl slog.Level) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
LogAttrs(nil, level, msg, attrs...) // already logs attributes
|
||||
}
|
||||
|
||||
func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
now := time.Now()
|
||||
n := len(now.AppendFormat(timebuf[:0], timefmt))
|
||||
LogAllocs(msg)
|
||||
print("time=", unsafe.String(&timebuf[0], n), " ")
|
||||
if level == LevelTrace {
|
||||
print("TRACE ")
|
||||
@@ -35,7 +37,6 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
print(level.String(), " ")
|
||||
}
|
||||
print(msg)
|
||||
|
||||
for _, a := range attrs {
|
||||
switch a.Value.Kind() {
|
||||
case slog.KindString:
|
||||
@@ -49,12 +50,5 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
}
|
||||
}
|
||||
println()
|
||||
allocmu.Lock()
|
||||
runtime.ReadMemStats(&memstats)
|
||||
if lastAllocs != memstats.TotalAlloc {
|
||||
print("alloc increase in heaplog")
|
||||
}
|
||||
lastAllocs = memstats.TotalAlloc
|
||||
lastMallocs = memstats.Mallocs
|
||||
allocmu.Unlock()
|
||||
LogAllocs(msg)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,9 @@ func LogAttrs(l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
|
||||
l.LogAttrs(context.Background(), level, msg, attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
LogAllocs(allocmsg)
|
||||
LogAttrs(l, level, msg, attrs...)
|
||||
LogAllocs(msg) // Should not log again unless LogAttrs allocated.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package ltesto
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// NewSched creates a cooperative two-goroutine scheduler modelling a
|
||||
// coroutine handoff: the scheduled (stack) goroutine drives the [SchedGoro] handle
|
||||
// while the controlling test thread drives the [SchedDriver] handle. Splitting the
|
||||
// API across two handles makes it impossible to call a goroutine-side method
|
||||
// from the test thread, or vice versa.
|
||||
func NewSched(t testing.TB) *Sched {
|
||||
return &Sched{
|
||||
t: t,
|
||||
goroYieldSignal: make(chan struct{}),
|
||||
goroContinueSignal: make(chan struct{}),
|
||||
finishChan: make(chan error, 1),
|
||||
timeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Sched is the shared state behind a [SchedGoro]/[SchedDriver] pair. It exposes no
|
||||
// handoff methods directly; obtain a handle with [Sched.Goro] (for the
|
||||
// scheduled goroutine) or [Sched.Driver] (for the test thread).
|
||||
type Sched struct {
|
||||
t testing.TB
|
||||
// when stack backs off it signals here and waits until channel read or timeout.
|
||||
goroYieldSignal chan struct{}
|
||||
// when main goroutine is ready for more information this channel is written to to signal waiting on stack activity.
|
||||
goroContinueSignal chan struct{}
|
||||
finishChan chan error
|
||||
finishcalled atomic.Bool
|
||||
coroCalls atomic.Int32
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// AwaitGoroYield blocks until the coroutine suspends itself via [SchedGoro.Yield].
|
||||
func (ss *Sched) AwaitGoroYield() {
|
||||
select {
|
||||
case <-ss.goroYieldSignal:
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout waiting for stack to backoff")
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitGoroYieldOrDone blocks until the coroutine either parks itself via
|
||||
// [SchedGoro.Yield] (returning done=false) or terminates via [SchedGoro.FinishWithErr]
|
||||
// /[SchedGoro.Finish] (returning done=true and the terminal error). It lets a driver
|
||||
// loop service an a-priori-unknown number of yields and still observe completion in
|
||||
// the same select, avoiding the deadlock of guessing whether the goroutine will yield
|
||||
// again. Do not mix with [Sched.Done] on the same scheduler.
|
||||
func (ss *Sched) AwaitGoroYieldOrDone() (done bool, err error) {
|
||||
select {
|
||||
case <-ss.goroYieldSignal:
|
||||
return false, nil
|
||||
case err = <-ss.finishChan:
|
||||
return true, err
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout waiting for stack to yield or finish")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// YieldToGoro wakes a coroutine parked in [SchedGoro.Yield], letting the goroutine run on.
|
||||
func (ss *Sched) YieldToGoro() {
|
||||
select {
|
||||
case ss.goroContinueSignal <- struct{}{}:
|
||||
case <-time.After(ss.timeout):
|
||||
ss.t.Fatal("timeout while trying to yield to stack")
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns the channel that receives the coroutine's terminal error from
|
||||
// [SchedGoro.FinishWithErr]. It may only be called once.
|
||||
func (ss *Sched) Done() <-chan error {
|
||||
if ss.finishcalled.CompareAndSwap(false, true) {
|
||||
return ss.finishChan
|
||||
}
|
||||
panic("Done called twice")
|
||||
}
|
||||
|
||||
// Goro returns the handle whose methods must be called from inside the
|
||||
// scheduled (stack) goroutine.
|
||||
func (ss *Sched) Goro() SchedGoro {
|
||||
if !ss.coroCalls.CompareAndSwap(0, 1) {
|
||||
panic("only one goroutine supported for now")
|
||||
}
|
||||
return SchedGoro{ss: ss}
|
||||
}
|
||||
|
||||
// SchedGoro is the coroutine-side handle of a [Sched]. Every method MUST be
|
||||
// called from inside the scheduled goroutine and never from the test thread.
|
||||
type SchedGoro struct{ ss *Sched }
|
||||
|
||||
// Yield suspends the goroutine at a backoff point and parks until the driver
|
||||
// calls [SchedDriver.YieldToGoro]. Its signature satisfies [lneto.BackoffStrategy] so it
|
||||
// can be passed directly as the stack's backoff strategy.
|
||||
func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
|
||||
ss := c.ss
|
||||
timeout := time.After(ss.timeout)
|
||||
select {
|
||||
case ss.goroYieldSignal <- struct{}{}:
|
||||
case <-timeout:
|
||||
ss.t.Fatal("timeout backing off, possible race condition? Multiple stacks using same backoff is unexpected pattern")
|
||||
}
|
||||
select {
|
||||
case <-ss.goroContinueSignal:
|
||||
case <-timeout:
|
||||
ss.t.Fatal("timeout waiting for continue")
|
||||
}
|
||||
return lneto.BackoffFlagNop // backoff yield implemented on our side.
|
||||
}
|
||||
|
||||
// FinishWithErr terminates the coroutine, handing err to the driver's [SchedDriver.Done]
|
||||
// channel. It must be called at most once.
|
||||
func (c SchedGoro) FinishWithErr(err error) {
|
||||
ss := c.ss
|
||||
if len(ss.finishChan) != 0 {
|
||||
ss.t.Fatal("Coro.FinishWithErr can be called once only")
|
||||
}
|
||||
ss.finishChan <- err
|
||||
}
|
||||
|
||||
// Finish is just shorthand for c.FinishWithErr(nil).
|
||||
func (c SchedGoro) Finish() {
|
||||
c.FinishWithErr(nil)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var (
|
||||
ErrRingBufferFull = lneto.ErrBufferFull
|
||||
errRingNoData = errors.New("lneto/ring: empty write")
|
||||
errInvalidCommit = errors.New("lneto/ring: invalid commit amount")
|
||||
errInvalidDiscard = errors.New("lneto/ring: invalid discard amount")
|
||||
errDiscardExceeds = errors.New("lneto/ring: discard exceeds length")
|
||||
errOffsetOverflow = errors.New("lneto/ring: offset too large (32 bit overflow)")
|
||||
@@ -92,6 +93,58 @@ func (r *Ring) Write(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// writeStart returns the buffer index where the next [Ring.Write] or
|
||||
// [Ring.Commit] begins, matching [Ring.Write]'s placement (including wrap).
|
||||
func (r *Ring) writeStart() int {
|
||||
if r.End == 0 {
|
||||
return r.Off // Empty: writing begins at Off.
|
||||
}
|
||||
if r.End == len(r.Buf) {
|
||||
return 0 // Tail full: next byte wraps to the start.
|
||||
}
|
||||
return r.End
|
||||
}
|
||||
|
||||
// PeekWrite stages b offset bytes past the write position (see
|
||||
// [Ring.writeStart]) without advancing it, so the bytes are not yet readable; a
|
||||
// later [Ring.Commit] reveals them. It reports false, writing nothing, when
|
||||
// offset is negative or offset+len(b) exceeds [Ring.Free]. Used to place
|
||||
// out-of-order data ahead of a gap that a normal Write later fills.
|
||||
func (r *Ring) PeekWrite(b []byte, offset int) bool {
|
||||
if offset < 0 || offset+len(b) > r.Free() {
|
||||
return false
|
||||
}
|
||||
off := r.writeStart() + offset
|
||||
if off >= len(r.Buf) {
|
||||
off -= len(r.Buf)
|
||||
}
|
||||
n := copy(r.Buf[off:], b)
|
||||
if n < len(b) {
|
||||
copy(r.Buf, b[n:])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Commit advances the write pointer by n bytes, making readable any bytes
|
||||
// previously staged with [Ring.PeekWrite]. It copies nothing and errors if n is
|
||||
// not positive or exceeds [Ring.Free].
|
||||
func (r *Ring) Commit(n int) error {
|
||||
if n <= 0 {
|
||||
return errInvalidCommit
|
||||
} else if n > r.Free() {
|
||||
return ErrRingBufferFull
|
||||
}
|
||||
if r.End == 0 {
|
||||
r.End = r.Off // Match Write: commit begins at Off when empty.
|
||||
}
|
||||
end := r.End + n
|
||||
if end > len(r.Buf) {
|
||||
end -= len(r.Buf)
|
||||
}
|
||||
r.End = end // Never 0 here: end==len(Buf) is kept.
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadDiscard is a performance auxiliary method that performs a dummy read or no-op read
|
||||
// for advancing the read pointer n bytes without actually copying data.
|
||||
// This method panics if amount of bytes is more than buffered (see [Ring.Buffered]).
|
||||
|
||||
@@ -580,3 +580,84 @@ func canonRing(r *Ring) {
|
||||
r.onReadEnd(1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingPeekWriteCommit verifies that bytes staged ahead of a gap with
|
||||
// PeekWrite become readable in order once the gap is filled and committed.
|
||||
func TestRingPeekWriteCommit(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 16)}
|
||||
// Stage "BBBB" 4 bytes ahead of the write position (the gap).
|
||||
if !r.PeekWrite([]byte("BBBB"), 4) {
|
||||
t.Fatal("PeekWrite should fit")
|
||||
}
|
||||
// Staged bytes are not yet readable.
|
||||
if r.Buffered() != 0 {
|
||||
t.Fatalf("staged bytes must not be readable, buffered=%d", r.Buffered())
|
||||
}
|
||||
// Fill the gap with a normal write, then commit the staged tail.
|
||||
if _, err := r.Write([]byte("AAAA")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if err := r.Commit(4); err != nil {
|
||||
t.Fatal("commit:", err)
|
||||
}
|
||||
got := make([]byte, 8)
|
||||
n, err := r.Read(got)
|
||||
if err != nil {
|
||||
t.Fatal("read:", err)
|
||||
}
|
||||
if string(got[:n]) != "AAAABBBB" {
|
||||
t.Fatalf("read %q, want AAAABBBB", got[:n])
|
||||
}
|
||||
testRingSanity(t, r)
|
||||
}
|
||||
|
||||
// TestRingPeekWriteWrap exercises PeekWrite/Commit when the staged region wraps
|
||||
// across the end of the backing buffer. Existing data at Off=2,End=6 puts the
|
||||
// write position at index 6, so a 2-byte gap fills indices 6,7 and the staged
|
||||
// tail wraps to indices 0,1.
|
||||
func TestRingPeekWriteWrap(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 8)}
|
||||
setRingData(t, r, 2, []byte("WXYZ")) // Off=2, End=6, 4 bytes buffered.
|
||||
if r.writeStart() != 6 {
|
||||
t.Fatalf("writeStart=%d, want 6", r.writeStart())
|
||||
}
|
||||
// Stage "CD" 2 bytes ahead of the write position (6) → wraps to indices 0,1.
|
||||
if !r.PeekWrite([]byte("CD"), 2) {
|
||||
t.Fatal("PeekWrite (wrap) should fit")
|
||||
}
|
||||
// Fill the 2-byte gap at indices 6,7, then commit the wrapped tail.
|
||||
if _, err := r.Write([]byte("AB")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if err := r.Commit(2); err != nil {
|
||||
t.Fatal("commit:", err)
|
||||
}
|
||||
got := make([]byte, 8)
|
||||
n, err := r.Read(got)
|
||||
if err != nil {
|
||||
t.Fatal("read:", err)
|
||||
}
|
||||
if string(got[:n]) != "WXYZABCD" {
|
||||
t.Fatalf("read %q, want WXYZABCD", got[:n])
|
||||
}
|
||||
testRingSanity(t, r)
|
||||
}
|
||||
|
||||
func TestRingPeekWriteRejects(t *testing.T) {
|
||||
r := &Ring{Buf: make([]byte, 8)}
|
||||
if r.PeekWrite([]byte("toolong!!"), 0) {
|
||||
t.Error("PeekWrite must reject data larger than the buffer")
|
||||
}
|
||||
if r.PeekWrite([]byte("data"), 5) { // 5+4 > 8 free.
|
||||
t.Error("PeekWrite must reject offset+len beyond free space")
|
||||
}
|
||||
if r.PeekWrite([]byte("x"), -1) {
|
||||
t.Error("PeekWrite must reject negative offset")
|
||||
}
|
||||
if err := r.Commit(0); err == nil {
|
||||
t.Error("Commit(0) must error")
|
||||
}
|
||||
if err := r.Commit(9); err == nil {
|
||||
t.Error("Commit beyond free must error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package internal
|
||||
|
||||
// IntLen returns the number of bytes [strconv.AppendInt] emits for value in the
|
||||
// given base, including a leading minus sign for negatives. Lets callers size a
|
||||
// buffer, or test whether a value fits an existing slot, before writing a byte.
|
||||
// base must be in the range 2..36, as accepted by [strconv.AppendInt].
|
||||
func IntLen(value int64, base int) int {
|
||||
n := 1
|
||||
u := uint64(value)
|
||||
if value < 0 {
|
||||
n++ // Leading minus sign.
|
||||
u = -u // Two's-complement magnitude; correct even for math.MinInt64.
|
||||
}
|
||||
for u >= uint64(base) {
|
||||
u /= uint64(base)
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
+20
-11
@@ -4,8 +4,8 @@ package pcap
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
@@ -35,7 +35,7 @@ var (
|
||||
)
|
||||
|
||||
type PacketBreakdown struct {
|
||||
hdr httpraw.Header
|
||||
hdr httpraw.HeaderV1
|
||||
dmsg dns.Message
|
||||
vld lneto.Validator
|
||||
// SubfieldLimit will limit the number of captured subfields to the value it has.
|
||||
@@ -768,6 +768,11 @@ func httpBodyClass(contentType, body []byte) FieldClass {
|
||||
return FieldClassText
|
||||
}
|
||||
|
||||
// maxCapturedHeaderFields bounds the field table a captured HTTP header is
|
||||
// parsed into. Captures are read once and discarded, so the table is sized for
|
||||
// a realistic request rather than for whatever the capture happens to hold.
|
||||
const maxCapturedHeaderFields = 64
|
||||
|
||||
func (pc *PacketBreakdown) CaptureHTTP(dst []Frame, pkt []byte, bitOffset int) ([]Frame, error) {
|
||||
debuglog("pcap:http:start")
|
||||
const httpProtocol = "HTTP"
|
||||
@@ -777,10 +782,10 @@ func (pc *PacketBreakdown) CaptureHTTP(dst []Frame, pkt []byte, bitOffset int) (
|
||||
const asResponse = true
|
||||
const asRequest = false
|
||||
httpData := pkt[bitOffset/8:]
|
||||
pc.hdr.Reset(httpData)
|
||||
pc.hdr.Reset(httpData, maxCapturedHeaderFields)
|
||||
err := pc.hdr.Parse(asResponse)
|
||||
if err != nil {
|
||||
pc.hdr.Reset(httpData)
|
||||
pc.hdr.Reset(httpData, 0) // Field table already sized, reuse it.
|
||||
err = pc.hdr.Parse(asRequest) // try as request.
|
||||
}
|
||||
if err != nil {
|
||||
@@ -980,18 +985,22 @@ func (frm Frame) String() string {
|
||||
|
||||
func (frm Frame) AppendString(b []byte) []byte {
|
||||
bitlen := frm.LenBits()
|
||||
b = fmt.Appendf(b, "%s", frm.Protocol)
|
||||
b = append(b, frm.Protocol...)
|
||||
if bitlen%8 == 0 {
|
||||
b = fmt.Appendf(b, " len=%d", bitlen/8)
|
||||
b = append(b, " len="...)
|
||||
b = strconv.AppendInt(b, int64(bitlen/8), 10)
|
||||
} else {
|
||||
b = fmt.Appendf(b, " bits=%d", bitlen)
|
||||
b = append(b, " bits="...)
|
||||
b = strconv.AppendInt(b, int64(bitlen), 10)
|
||||
}
|
||||
iopt, err := frm.FieldByClass(FieldClassOptions)
|
||||
if err == nil {
|
||||
b = fmt.Appendf(b, " optlen=%d", (frm.Fields[iopt].BitLength+7)/8)
|
||||
b = append(b, " optlen="...)
|
||||
b = strconv.AppendInt(b, int64((frm.Fields[iopt].BitLength+7)/8), 10)
|
||||
}
|
||||
for _, err := range frm.Errors {
|
||||
b = fmt.Appendf(b, " %s", err.Error())
|
||||
b = append(b, ' ')
|
||||
b = append(b, err.Error()...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1005,10 +1014,10 @@ func (frm Frame) LenBits() (totalBitlen int) {
|
||||
|
||||
func (ff FrameField) String() string {
|
||||
if ff.Class == FieldClassPayload {
|
||||
return fmt.Sprintf("Payload len=%d", ff.BitLength/8)
|
||||
return "Payload len=" + strconv.Itoa(ff.BitLength/8)
|
||||
}
|
||||
if ff.Name != "" {
|
||||
return fmt.Sprintf("%s (%s)", ff.Name, ff.Class.String())
|
||||
return ff.Name + " (" + ff.Class.String() + ")"
|
||||
}
|
||||
return ff.Class.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package pcap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/dhcp/dhcpv4"
|
||||
"github.com/soypat/lneto/dns"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
"github.com/soypat/lneto/udp"
|
||||
)
|
||||
|
||||
const benchSubfieldLimit = 32
|
||||
|
||||
// buildDHCPPacket builds an Ethernet+IPv4+UDP+DHCPv4 Discover packet, exercising
|
||||
// the option-heavy DHCP path (hostname, client id, requested address, param list).
|
||||
func buildDHCPPacket(b testing.TB) []byte {
|
||||
const (
|
||||
ethSize = 14
|
||||
ipv4Size = 20
|
||||
udpSize = 8
|
||||
)
|
||||
pkt := make([]byte, 600)
|
||||
|
||||
efrm, _ := ethernet.NewFrame(pkt)
|
||||
*efrm.DestinationHardwareAddr() = [6]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
|
||||
*efrm.SourceHardwareAddr() = [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}
|
||||
efrm.SetEtherType(ethernet.TypeIPv4)
|
||||
|
||||
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
|
||||
ifrm.SetVersionAndIHL(4, 5)
|
||||
ifrm.SetID(0x1234)
|
||||
ifrm.SetFlags(0x4000)
|
||||
ifrm.SetTTL(64)
|
||||
ifrm.SetProtocol(lneto.IPProtoUDP)
|
||||
|
||||
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
|
||||
ufrm.SetSourcePort(dhcpv4.DefaultClientPort)
|
||||
ufrm.SetDestinationPort(dhcpv4.DefaultServerPort)
|
||||
|
||||
var cl dhcpv4.Client
|
||||
err := cl.BeginRequest(0xdeadbeef, dhcpv4.RequestConfig{
|
||||
RequestedAddr: [4]byte{192, 168, 1, 100},
|
||||
ClientHardwareAddr: [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe},
|
||||
Hostname: "myhost",
|
||||
ClientID: "lneto-test",
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal("begin request:", err)
|
||||
}
|
||||
dhcpLen, err := cl.Encapsulate(pkt, ethSize, ethSize+ipv4Size+udpSize)
|
||||
if err != nil {
|
||||
b.Fatal("encapsulate:", err)
|
||||
}
|
||||
totalLen := ipv4Size + udpSize + dhcpLen
|
||||
ifrm.SetTotalLength(uint16(totalLen))
|
||||
ufrm.SetLength(uint16(udpSize + dhcpLen))
|
||||
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
|
||||
return pkt[:ethSize+totalLen]
|
||||
}
|
||||
|
||||
// buildDNSPacket builds an Ethernet+IPv4+UDP+DNS message with multiple questions
|
||||
// and answers, exercising name encoding and resource record rendering.
|
||||
func buildDNSPacket(b testing.TB) []byte {
|
||||
const (
|
||||
ethSize = 14
|
||||
ipv4Size = 20
|
||||
udpSize = 8
|
||||
)
|
||||
var msg dns.Message
|
||||
msg.Questions = []dns.Question{
|
||||
{Name: dns.MustNewName("example.com"), Type: dns.TypeA, Class: dns.ClassINET},
|
||||
{Name: dns.MustNewName("temu.com"), Type: dns.TypeAAAA, Class: dns.ClassANY},
|
||||
}
|
||||
msg.Answers = []dns.Resource{
|
||||
dns.NewResource(dns.MustNewName("abc.com"), dns.TypeALL, dns.ClassANY, 64, []byte{10, 0, 11, 1}),
|
||||
dns.NewResource(dns.MustNewName("123.com"), dns.TypeA, dns.ClassINET, 64, []byte{20, 0, 22, 2}),
|
||||
}
|
||||
dnsPayload, err := msg.AppendTo(nil, 0x1234, dns.NewClientHeaderFlags(dns.OpCodeQuery, true))
|
||||
if err != nil {
|
||||
b.Fatal("dns encode:", err)
|
||||
}
|
||||
|
||||
pkt := make([]byte, ethSize+ipv4Size+udpSize+len(dnsPayload))
|
||||
|
||||
efrm, _ := ethernet.NewFrame(pkt)
|
||||
*efrm.DestinationHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01}
|
||||
*efrm.SourceHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x02}
|
||||
efrm.SetEtherType(ethernet.TypeIPv4)
|
||||
|
||||
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
|
||||
ifrm.SetVersionAndIHL(4, 5)
|
||||
ifrm.SetTTL(64)
|
||||
ifrm.SetProtocol(lneto.IPProtoUDP)
|
||||
ifrm.SetTotalLength(uint16(ipv4Size + udpSize + len(dnsPayload)))
|
||||
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
|
||||
|
||||
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
|
||||
ufrm.SetSourcePort(58200)
|
||||
ufrm.SetDestinationPort(dns.ServerPort)
|
||||
ufrm.SetLength(uint16(udpSize + len(dnsPayload)))
|
||||
|
||||
copy(pkt[ethSize+ipv4Size+udpSize:], dnsPayload)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func configureBenchFormatter(f *Formatter) {
|
||||
f.SubfieldLimit = benchSubfieldLimit
|
||||
f.FrameSep = "\n"
|
||||
f.FieldSep = "; "
|
||||
f.SubfieldSep = "\n\t"
|
||||
}
|
||||
|
||||
// BenchmarkPcap measures the decode, format, and decode+format (roundtrip) phases
|
||||
// separately for the string-heavy DHCP and DNS frames. Run with -benchmem for
|
||||
// per-phase allocs/op.
|
||||
func BenchmarkPcap(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
}{
|
||||
{"DHCP", buildDHCPPacket(b)},
|
||||
{"DNS", buildDNSPacket(b)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
b.Run("decode", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var frames []Frame
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
}
|
||||
})
|
||||
b.Run("format", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
frames, err := pb.CaptureEthernet(nil, tc.pkt, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var buf []byte
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
}
|
||||
})
|
||||
b.Run("roundtrip", func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var frames []Frame
|
||||
var buf []byte
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPcapPhases runs decode+format in a single benchmark loop while
|
||||
// reporting per-phase wall time via custom metrics (decode-ns/op, format-ns/op).
|
||||
// decode-ns/op + format-ns/op approximates ns/op minus time.Now overhead.
|
||||
// Per-phase allocs are not split here (ReadMemStats is STW and skews timing);
|
||||
// use BenchmarkPcap's decode/format sub-benchmarks with -benchmem for that.
|
||||
func BenchmarkPcapPhases(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
}{
|
||||
{"DHCP", buildDHCPPacket(b)},
|
||||
{"DNS", buildDNSPacket(b)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
var pb PacketBreakdown
|
||||
pb.SubfieldLimit = benchSubfieldLimit
|
||||
var f Formatter
|
||||
configureBenchFormatter(&f)
|
||||
var frames []Frame
|
||||
var buf []byte
|
||||
var decNs, fmtNs int64
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
t0 := time.Now()
|
||||
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
|
||||
t1 := time.Now()
|
||||
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
|
||||
t2 := time.Now()
|
||||
decNs += t1.Sub(t0).Nanoseconds()
|
||||
fmtNs += t2.Sub(t1).Nanoseconds()
|
||||
}
|
||||
b.ReportMetric(float64(decNs)/float64(b.N), "decode-ns/op")
|
||||
b.ReportMetric(float64(fmtNs)/float64(b.N), "format-ns/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
const httpProtocol = "HTTP/1.1"
|
||||
|
||||
func makeHttpPayload(body string) ([]byte, error) {
|
||||
var hdr httpraw.Header
|
||||
var hdr httpraw.HeaderV1
|
||||
hdr.SetProtocol(httpProtocol)
|
||||
hdr.SetStatus("200", "OK")
|
||||
hdr.Set("Cookie", "ABC=123")
|
||||
|
||||
@@ -33,8 +33,9 @@ const _FieldClass_name = "undefinedsourcedestinationprotocolversiontypesizeflags
|
||||
var _FieldClass_index = [...]uint8{0, 9, 15, 26, 34, 41, 45, 49, 54, 68, 76, 83, 90, 94, 101, 112, 114, 123, 131}
|
||||
|
||||
func (i FieldClass) String() string {
|
||||
if i >= FieldClass(len(_FieldClass_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_FieldClass_index)-1 {
|
||||
return "FieldClass(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _FieldClass_name[_FieldClass_index[i]:_FieldClass_index[i+1]]
|
||||
return _FieldClass_name[_FieldClass_index[idx]:_FieldClass_index[idx+1]]
|
||||
}
|
||||
|
||||
@@ -158,7 +158,12 @@ func (ls *StackEthernet) Demux(carrierData []byte, frameOffset int) (err error)
|
||||
return err
|
||||
}
|
||||
DROP:
|
||||
ls.handlers.info("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
|
||||
// Frames not addressed to us are routine noise on a shared LAN (multicast/
|
||||
// broadcast spam). Log at debug so a production logger at info level gates it
|
||||
// out instead of allocating slog attrs per dropped packet.
|
||||
if internal.LogEnabled(ls.handlers.logger.log, slog.LevelDebug) {
|
||||
ls.handlers.debug("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
|
||||
}
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -18,6 +18,12 @@ func IsMulticast(addr [4]byte) bool {
|
||||
return addr[0]&0xf0 == 0xe0
|
||||
}
|
||||
|
||||
// UnspecifiedAddr returns the unspecified address: 0.0.0.0
|
||||
func UnspecifiedAddr() [4]byte { return [4]byte{0, 0, 0, 0} }
|
||||
|
||||
// BroadcastAddr returns the broadcast address: 255.255.255.255
|
||||
func BroadcastAddr() [4]byte { return [4]byte{255, 255, 255, 255} }
|
||||
|
||||
// IsBroadcast reports whether addr is the limited broadcast address
|
||||
// 255.255.255.255 used to address all hosts on the local network segment
|
||||
// as defined in [RFC919]. It does not detect directed (subnet) broadcast
|
||||
@@ -26,7 +32,7 @@ func IsMulticast(addr [4]byte) bool {
|
||||
//
|
||||
// [RFC919]: https://datatracker.ietf.org/doc/html/rfc919
|
||||
func IsBroadcast(addr [4]byte) bool {
|
||||
return addr == [4]byte{255, 255, 255, 255}
|
||||
return addr == BroadcastAddr()
|
||||
}
|
||||
|
||||
// IsLinkLocal reports whether addr is within the IPv4 link-local prefix
|
||||
@@ -113,3 +119,12 @@ func AppendFormatAddr(dst []byte, addr [4]byte) []byte {
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// String returns the dotted-decimal text representation of an IPv4 address.
|
||||
func String(addr [4]byte) string {
|
||||
// See net/netip's (Addr).string4 pattern.
|
||||
var buf [maxAddrStringLen]byte
|
||||
return string(AppendFormatAddr(buf[:0], addr))
|
||||
}
|
||||
|
||||
const maxAddrStringLen = len("255.255.255.255")
|
||||
|
||||
@@ -10,10 +10,10 @@ func TestAppendFormatAddr(t *testing.T) {
|
||||
addr [4]byte
|
||||
want string
|
||||
}{
|
||||
{addr: [4]byte{0, 0, 0, 0}, want: "0.0.0.0"},
|
||||
{addr: UnspecifiedAddr(), want: "0.0.0.0"},
|
||||
{addr: BroadcastAddr(), want: "255.255.255.255"},
|
||||
{addr: [4]byte{127, 0, 0, 1}, want: "127.0.0.1"},
|
||||
{addr: [4]byte{192, 168, 1, 1}, want: "192.168.1.1"},
|
||||
{addr: [4]byte{255, 255, 255, 255}, want: "255.255.255.255"},
|
||||
{addr: [4]byte{10, 0, 0, 1}, want: "10.0.0.1"},
|
||||
{addr: [4]byte{1, 2, 3, 4}, want: "1.2.3.4"},
|
||||
{addr: [4]byte{100, 99, 9, 0}, want: "100.99.9.0"},
|
||||
|
||||
@@ -38,6 +38,10 @@ type Client struct {
|
||||
raddr [4]byte
|
||||
}
|
||||
responseRing internal.Ring
|
||||
// addrScratch holds the remote address for SetIPAddrs during Encapsulate.
|
||||
// Kept on the (heap-resident) Client to avoid a per-call escape of a local
|
||||
// [4]byte, which TinyGo would otherwise heap-allocate on every poll.
|
||||
addrScratch [4]byte
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
@@ -155,7 +159,6 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
|
||||
// Put n bytes of ICMP data.
|
||||
var n int
|
||||
var raddr [4]byte
|
||||
if len(client.incomingEcho) > 0 {
|
||||
// Priority: send echo reply.1
|
||||
inc := client.incomingEcho[0]
|
||||
@@ -170,7 +173,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
}
|
||||
client.incomingEcho = slices.Delete(client.incomingEcho, 0, 1)
|
||||
n = sizeHeader + dataLen
|
||||
raddr = inc.raddr
|
||||
client.addrScratch = inc.raddr
|
||||
} else if len(client.outgoingEcho) > 0 {
|
||||
idx := 0
|
||||
for idx < len(client.outgoingEcho) {
|
||||
@@ -199,7 +202,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
copy(data[written:written+size%len(pattern)], pattern)
|
||||
n = sizeHeader + size
|
||||
out.key |= keyHashSentBit
|
||||
raddr = out.raddr
|
||||
client.addrScratch = out.raddr
|
||||
} else {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -210,7 +213,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
|
||||
sum := crc.PayloadSum16(carrierData[frameOffset : frameOffset+n])
|
||||
ifrm.SetCRC(sum)
|
||||
if ipOffset >= 0 {
|
||||
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, raddr[:])
|
||||
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, client.addrScratch[:])
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -66,10 +66,11 @@ const _CodeDestinationUnreachable_name = "net unreachablehost unreachableprotoco
|
||||
var _CodeDestinationUnreachable_index = [...]uint8{0, 15, 31, 51, 67, 98, 117}
|
||||
|
||||
func (i CodeDestinationUnreachable) String() string {
|
||||
if i >= CodeDestinationUnreachable(len(_CodeDestinationUnreachable_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_CodeDestinationUnreachable_index)-1 {
|
||||
return "CodeDestinationUnreachable(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _CodeDestinationUnreachable_name[_CodeDestinationUnreachable_index[i]:_CodeDestinationUnreachable_index[i+1]]
|
||||
return _CodeDestinationUnreachable_name[_CodeDestinationUnreachable_index[idx]:_CodeDestinationUnreachable_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -86,8 +87,9 @@ const _CodeRedirect_name = "redirect for networkredirect for hostredirect for To
|
||||
var _CodeRedirect_index = [...]uint8{0, 20, 37, 61, 82}
|
||||
|
||||
func (i CodeRedirect) String() string {
|
||||
if i >= CodeRedirect(len(_CodeRedirect_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_CodeRedirect_index)-1 {
|
||||
return "CodeRedirect(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _CodeRedirect_name[_CodeRedirect_index[i]:_CodeRedirect_index[i+1]]
|
||||
return _CodeRedirect_name[_CodeRedirect_index[idx]:_CodeRedirect_index[idx+1]]
|
||||
}
|
||||
|
||||
@@ -66,10 +66,11 @@ const _CodeDestinationUnreachable_name = "no route to destinationcommunication a
|
||||
var _CodeDestinationUnreachable_index = [...]uint8{0, 23, 64, 94, 113, 129, 172, 199}
|
||||
|
||||
func (i CodeDestinationUnreachable) String() string {
|
||||
if i >= CodeDestinationUnreachable(len(_CodeDestinationUnreachable_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_CodeDestinationUnreachable_index)-1 {
|
||||
return "CodeDestinationUnreachable(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _CodeDestinationUnreachable_name[_CodeDestinationUnreachable_index[i]:_CodeDestinationUnreachable_index[i+1]]
|
||||
return _CodeDestinationUnreachable_name[_CodeDestinationUnreachable_index[idx]:_CodeDestinationUnreachable_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
@@ -85,8 +86,9 @@ const _CodeParameterProblem_name = "erroneous header field encounteredunrecogniz
|
||||
var _CodeParameterProblem_index = [...]uint8{0, 34, 75, 111}
|
||||
|
||||
func (i CodeParameterProblem) String() string {
|
||||
if i >= CodeParameterProblem(len(_CodeParameterProblem_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_CodeParameterProblem_index)-1 {
|
||||
return "CodeParameterProblem(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _CodeParameterProblem_name[_CodeParameterProblem_index[i]:_CodeParameterProblem_index[i+1]]
|
||||
return _CodeParameterProblem_name[_CodeParameterProblem_index[idx]:_CodeParameterProblem_index[idx+1]]
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ const _LinkMode_name = "down10M-H10M-F100M-H100M-F100M-T41000M-H1000M-F2.5G-F5G-
|
||||
var _LinkMode_index = [...]uint8{0, 4, 9, 14, 20, 26, 33, 40, 47, 53, 57, 62, 67, 72, 78}
|
||||
|
||||
func (i LinkMode) String() string {
|
||||
if i >= LinkMode(len(_LinkMode_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_LinkMode_index)-1 {
|
||||
return "LinkMode(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _LinkMode_name[_LinkMode_index[i]:_LinkMode_index[i+1]]
|
||||
return _LinkMode_name[_LinkMode_index[idx]:_LinkMode_index[idx+1]]
|
||||
}
|
||||
|
||||
+4
-3
@@ -212,16 +212,17 @@ func _() {
|
||||
_ = x[ErrAlreadyRegistered-17]
|
||||
_ = x[ErrTruncatedFrame-18]
|
||||
_ = x[ErrMissingHALConfig-19]
|
||||
_ = x[ErrBadState-20]
|
||||
}
|
||||
|
||||
const (
|
||||
_errGeneric_name_0 = "lneto-bug(use build tag \"debugheaplog\")packet droppedincorrect checksumzero source(port/addr)zero destination(port/addr)short bufferbuffer fullinvalid addressunsupportedmismatchmismatched lengthinvalid configuration"
|
||||
_errGeneric_name_1 = "invalid fieldinvalid length fieldresource exhaustedprotocol already registeredtruncated framemissing HAL configuration"
|
||||
_errGeneric_name_1 = "invalid fieldinvalid length fieldresource exhaustedprotocol already registeredtruncated framemissing HAL configurationoperation invalid in current state"
|
||||
)
|
||||
|
||||
var (
|
||||
_errGeneric_index_0 = [...]uint8{0, 39, 53, 71, 93, 120, 132, 143, 158, 169, 177, 194, 215}
|
||||
_errGeneric_index_1 = [...]uint8{0, 13, 33, 51, 78, 93, 118}
|
||||
_errGeneric_index_1 = [...]uint8{0, 13, 33, 51, 78, 93, 118, 152}
|
||||
)
|
||||
|
||||
func (i errGeneric) String() string {
|
||||
@@ -229,7 +230,7 @@ func (i errGeneric) String() string {
|
||||
case 1 <= i && i <= 12:
|
||||
i -= 1
|
||||
return _errGeneric_name_0[_errGeneric_index_0[i]:_errGeneric_index_0[i+1]]
|
||||
case 14 <= i && i <= 19:
|
||||
case 14 <= i && i <= 20:
|
||||
i -= 14
|
||||
return _errGeneric_name_1[_errGeneric_index_1[i]:_errGeneric_index_1[i+1]]
|
||||
default:
|
||||
|
||||
+15
@@ -76,6 +76,16 @@ type ConnConfig struct {
|
||||
// Logger sets the [Conn] logger.
|
||||
// Lower level logging available at [Handler.SetLoggers] via [Conn.InternalHandler].
|
||||
Logger *slog.Logger
|
||||
// LossRecovery is the optional packet-loss recovery algorithm (RTO,
|
||||
// congestion control, ...) for the connection. If set, Nanotime must also be
|
||||
// set (else Configure returns an error). Leaving it nil disables loss
|
||||
// recovery. See [LossRecovery].
|
||||
LossRecovery LossRecovery
|
||||
// Nanotime is the monotonic time source in nanoseconds (the func() int64
|
||||
// convention used across lneto) that drives LossRecovery. It is required when
|
||||
// LossRecovery is set and unused otherwise. The tcp package reads it only to
|
||||
// stamp the loss-recovery hooks; it holds no clock itself.
|
||||
Nanotime func() int64
|
||||
}
|
||||
|
||||
// Configure should be called on any newly created connection before usage. See [ConnConfig].
|
||||
@@ -83,6 +93,10 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
|
||||
if config.RWBackoff == nil {
|
||||
return lneto.ErrMissingHALConfig
|
||||
}
|
||||
if config.LossRecovery != nil && config.Nanotime == nil {
|
||||
// The tcp package holds no clock: a loss-recovery algorithm cannot run without it.
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
err = conn.h.SetBuffers(config.TxBuf, config.RxBuf, config.TxPacketQueueSize)
|
||||
@@ -91,6 +105,7 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
|
||||
}
|
||||
conn._backoff = config.RWBackoff
|
||||
conn.logger.log = config.Logger
|
||||
conn.h.SetLossRecovery(config.LossRecovery, config.Nanotime)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,16 @@ func (tcb *ControlBlock) HasPendingRetransmit() bool {
|
||||
return tcb._state.TxDataOpen() && tcb.dupack >= retransmitAfterDupacks && tcb.nRetransmit <= tcb.dupack-retransmitAfterDupacks
|
||||
}
|
||||
|
||||
// RetransmitAll rewinds snd.NXT back to snd.UNA so the next PendingSegment and
|
||||
// Send calls retransmit all unacknowledged data from the oldest sequence number
|
||||
// (go-back-N). It must be paired with ringTx.RetransmitFromUNA to rewind the
|
||||
// transmit buffer. Implements RFC 9293 §3.10.8 (RETRANSMISSION TIMEOUT).
|
||||
func (tcb *ControlBlock) RetransmitAll() {
|
||||
tcb.snd.NXT = tcb.snd.UNA
|
||||
tcb.dupack = 0
|
||||
tcb.nRetransmit = 0
|
||||
}
|
||||
|
||||
// PendingSegment calculates a suitable next segment to send from a payload length.
|
||||
// It does not modify the ControlBlock state or pending segment queue.
|
||||
func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) {
|
||||
|
||||
+152
-11
@@ -28,7 +28,18 @@ type Handler struct {
|
||||
// connection is established via Open calls. This disambiguates whether
|
||||
// Read and Write calls belong to the current connection.
|
||||
|
||||
optcodec OptionCodec
|
||||
optcodec OptionCodec
|
||||
// reasm tracks out-of-order segments staged in bufRx's free region. Always
|
||||
// enabled once buffers are set (see [Handler.SetBuffers]).
|
||||
reasm reassembly
|
||||
// loss is the optional packet-loss recovery algorithm (RTO, congestion
|
||||
// control, ...) driven from the rx/tx hooks. nil disables loss recovery, in
|
||||
// which case the connection behaves as if no timing existed. nanotime is the
|
||||
// monotonic time source (nanoseconds) passed to those hooks; it is non-nil
|
||||
// whenever loss is non-nil (enforced by [Conn.Configure]). See [LossRecovery].
|
||||
loss LossRecovery
|
||||
nanotime func() int64
|
||||
|
||||
closing bool
|
||||
shutdownRx bool
|
||||
// nRetransmit stores the number of times the oldest packet was retransmit.
|
||||
@@ -64,9 +75,32 @@ func (h *Handler) SetBuffers(txbuf, rxbuf []byte, packets int) error {
|
||||
}
|
||||
h.scb.SetRecvWindow(Size(h.bufRx.Size()))
|
||||
h.bufRx.Reset()
|
||||
h.reasm.reset(maxReasmSegments)
|
||||
return h.bufTx.ResetOrReuse(txbuf, packets, 0)
|
||||
}
|
||||
|
||||
// SetLossRecovery installs the packet-loss recovery algorithm and the monotonic
|
||||
// time source (nanoseconds, the func() int64 convention used across lneto) that
|
||||
// drives it. The tcp package keeps no clock of its own; nanotime is read only to
|
||||
// stamp the rx/tx hooks (see [LossRecovery]). Passing loss == nil disables loss
|
||||
// recovery. It should be set before the connection is opened.
|
||||
func (h *Handler) SetLossRecovery(loss LossRecovery, nanotime func() int64) {
|
||||
h.loss = loss
|
||||
h.nanotime = nanotime
|
||||
}
|
||||
|
||||
func (h *Handler) lossEnabled() bool { return h.loss != nil }
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the connection
|
||||
// must next be serviced by a transmit attempt (e.g. an RTO expiry), or 0 when
|
||||
// there is no deadline or no loss recovery is configured. See [LossRecovery].
|
||||
func (h *Handler) NextDeadline() int64 {
|
||||
if h.loss == nil {
|
||||
return 0
|
||||
}
|
||||
return h.loss.NextDeadline()
|
||||
}
|
||||
|
||||
// LocalPort returns the local port of the connection. Returns 0 if the connection is closed and uninitialized.
|
||||
func (h *Handler) LocalPort() uint16 {
|
||||
return h.localPort
|
||||
@@ -125,15 +159,24 @@ func (h *Handler) reset(localPort, remotePort uint16, iss Value) {
|
||||
*h = Handler{
|
||||
connid: h.connid + 1,
|
||||
scb: h.scb,
|
||||
bufTx: h.bufTx,
|
||||
bufRx: h.bufRx,
|
||||
localPort: localPort,
|
||||
remotePort: remotePort,
|
||||
validator: h.validator,
|
||||
logger: h.logger,
|
||||
closing: false,
|
||||
shutdownRx: false,
|
||||
// Persist configuration across reopen:
|
||||
validator: h.validator,
|
||||
loss: h.loss,
|
||||
nanotime: h.nanotime,
|
||||
logger: h.logger,
|
||||
// persist memory across repoen:
|
||||
bufTx: h.bufTx,
|
||||
bufRx: h.bufRx,
|
||||
reasm: h.reasm,
|
||||
}
|
||||
if h.lossEnabled() {
|
||||
h.loss.Reset()
|
||||
}
|
||||
h.reasm.clear() // preserve metadata capacity across reopen, drop held segments.
|
||||
h.bufTx.ResetOrReuse(nil, 0, iss)
|
||||
h.bufRx.Reset()
|
||||
}
|
||||
@@ -163,15 +206,28 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return lneto.ErrMismatch
|
||||
}
|
||||
payload := tfrm.Payload()
|
||||
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
|
||||
return lneto.ErrBufferFull
|
||||
}
|
||||
segIncoming := tfrm.Segment(len(payload))
|
||||
if h.scb.IncomingIsKeepalive(segIncoming) {
|
||||
h.info("tcp.Handler:rx-keepalive", slog.Uint64("port", uint64(h.localPort)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notify loss recovery of the received segment (RTT sampling, timer
|
||||
// management) and let it drop the segment before processing if it asks to.
|
||||
if h.lossEnabled() && !h.loss.PreRx(segIncoming, h.nanotime()).Keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Out-of-order reassembly: buffer in-window data that arrived ahead of the
|
||||
// next expected sequence number before the ControlBlock (sequential-only)
|
||||
// would reject it. Buffered segments live in bufRx's free region.
|
||||
if h.reasm.enabled() && h.handleOutOfOrder(segIncoming, payload) {
|
||||
return nil
|
||||
}
|
||||
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
|
||||
return lneto.ErrBufferFull
|
||||
}
|
||||
|
||||
prevState := h.scb.State()
|
||||
prevUNA := h.scb.snd.UNA // Capture before Recv updates snd.UNA (RFC 6298 §5.3).
|
||||
err = h.scb.Recv(segIncoming)
|
||||
@@ -204,6 +260,11 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if segIncoming.DATALEN != 0 {
|
||||
// The just-accepted in-order segment may have filled a gap; deliver any
|
||||
// now-contiguous buffered segments.
|
||||
h.deliverReassembled()
|
||||
}
|
||||
if segIncoming.Flags.HasAny(FlagACK) {
|
||||
if segIncoming.ACK == prevUNA {
|
||||
// scb keeping track of duplicate acks.
|
||||
@@ -242,6 +303,54 @@ func (h *Handler) Recv(incomingPacket []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOutOfOrder buffers an in-window data segment that arrived ahead of the
|
||||
// next expected sequence number and queues a duplicate ACK so the sender fast-
|
||||
// retransmits the gap. It returns true when it has consumed the segment; false
|
||||
// leaves the segment to the ControlBlock (in-order data, control segments, old
|
||||
// or out-of-window segments, or when the reassembly buffer cannot hold it).
|
||||
func (h *Handler) handleOutOfOrder(seg Segment, payload []byte) bool {
|
||||
if h.shutdownRx {
|
||||
// Discard mode drops payloads, which would break the ring/rcv.NXT
|
||||
// lockstep reassemble relies on; do not buffer.
|
||||
return false
|
||||
}
|
||||
if seg.DATALEN == 0 || seg.Flags.HasAny(flagctl) {
|
||||
return false // only pure data segments are buffered out of order.
|
||||
}
|
||||
rcvNxt := h.scb.RecvNext()
|
||||
if seg.SEQ == rcvNxt {
|
||||
return false // in order: the ControlBlock handles it normally.
|
||||
}
|
||||
rcvWnd := h.scb.RecvWindow()
|
||||
if !seg.SEQ.InWindow(rcvNxt, rcvWnd) || !seg.Last().InWindow(rcvNxt, rcvWnd) {
|
||||
return false // old or out of window: let the ControlBlock decide.
|
||||
}
|
||||
if !h.reasm.store(&h.bufRx, rcvNxt, seg.SEQ, payload) {
|
||||
return false // no room: fall back to ControlBlock (challenge ACK).
|
||||
}
|
||||
h.scb.pending[0] |= FlagACK // duplicate ACK advertises the gap at rcv.NXT.
|
||||
h.trace("tcp.Handler:rx-ooo", slog.Uint64("seg.seq", uint64(seg.SEQ)), slog.Uint64("rcv.nxt", uint64(rcvNxt)))
|
||||
return true
|
||||
}
|
||||
|
||||
// deliverReassembled hands any now-contiguous out-of-order segments to the
|
||||
// receive stream, advancing rcv.NXT and queuing an ACK for what was delivered.
|
||||
func (h *Handler) deliverReassembled() {
|
||||
if h.reasm.buffered() == 0 {
|
||||
return
|
||||
}
|
||||
if h.shutdownRx {
|
||||
// Discard mode skips the gap-filling write, so staged bytes can no
|
||||
// longer be committed coherently; drop them (the peer retransmits).
|
||||
h.reasm.clear()
|
||||
return
|
||||
}
|
||||
if delivered := h.reasm.reassemble(&h.bufRx, h.scb.RecvNext()); delivered > 0 {
|
||||
h.scb.rcv.NXT.UpdateForward(delivered)
|
||||
h.scb.pending[0] |= FlagACK
|
||||
}
|
||||
}
|
||||
|
||||
// ShutdownRead activates local discard mode: incoming payload bytes are dropped
|
||||
// (ACK/SEQ still advance normally) and Read returns [io.EOF] immediately.
|
||||
// Not reversible within the lifetime of a connection.
|
||||
@@ -271,6 +380,18 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
if h.IsTxOver() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
var now int64
|
||||
if h.lossEnabled() {
|
||||
now = h.nanotime()
|
||||
if h.loss.PreTx(now).RetransmitAll {
|
||||
// Go-back-N retransmission directed by loss recovery: rewind the
|
||||
// send sequence and transmit buffer so unacknowledged data is resent
|
||||
// from snd.UNA. Done before the early short-circuit below so an
|
||||
// expired RTO retransmits even with no new data queued.
|
||||
h.scb.RetransmitAll()
|
||||
h.bufTx.RetransmitFromUNA()
|
||||
}
|
||||
}
|
||||
awaitingSyn := h.AwaitingSynSend()
|
||||
requeueControl := h.requeueControl
|
||||
buffered := h.bufTx.BufferedUnsent()
|
||||
@@ -328,7 +449,7 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
var ok bool
|
||||
maxPayload := len(b) - sizeHeaderTCP
|
||||
segment, ok = h.scb.PendingSegment(maxPayload)
|
||||
segment.WND = Size(h.bufRx.Free())
|
||||
segment.WND = h.recvWindow()
|
||||
if !ok {
|
||||
// No pending control segment or data to send. Yield.
|
||||
return 0, nil
|
||||
@@ -353,6 +474,9 @@ func (h *Handler) Send(b []byte) (int, error) {
|
||||
} else if prevState != h.scb.State() && h.logenabled(slog.LevelInfo) {
|
||||
h.info("tcp.Handler:tx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("oldState", prevState.String()), slog.String("newState", h.scb.State().String()), slog.String("txflags", segment.Flags.String()))
|
||||
}
|
||||
if h.lossEnabled() {
|
||||
h.loss.PostTx(segment, now)
|
||||
}
|
||||
h.requeueControl = false
|
||||
tfrm.SetSourcePort(h.localPort)
|
||||
tfrm.SetDestinationPort(h.remotePort)
|
||||
@@ -391,6 +515,9 @@ func (h *Handler) Read(b []byte) (n int, err error) {
|
||||
n, err = h.bufRx.Read(b)
|
||||
}
|
||||
if n > 0 {
|
||||
// Reading freed receive-buffer space; deliver any contiguous
|
||||
// out-of-order data that was waiting for room.
|
||||
h.deliverReassembled()
|
||||
h.maybeQueueWindowUpdate()
|
||||
}
|
||||
if n == 0 && err == nil {
|
||||
@@ -413,7 +540,7 @@ func (h *Handler) Read(b []byte) (n int, err error) {
|
||||
// space >= min(bufferSize/2, MSS). This applies uniformly including zero-window
|
||||
// recovery — the remote uses zero-window probes until enough space opens.
|
||||
func (h *Handler) maybeQueueWindowUpdate() {
|
||||
currentFree := Size(h.bufRx.Free())
|
||||
currentFree := h.recvWindow()
|
||||
lastAdvertised := h.scb.RecvWindow()
|
||||
if currentFree <= lastAdvertised {
|
||||
return // Window hasn't grown.
|
||||
@@ -454,7 +581,21 @@ func (h *Handler) FreeOutput() int {
|
||||
|
||||
// FreeInput returns the number of free bytes in the receive buffer.
|
||||
func (h *Handler) FreeInput() int {
|
||||
return h.bufRx.Free()
|
||||
return int(h.recvWindow())
|
||||
}
|
||||
|
||||
// recvWindow returns the receive window to advertise: free receive-buffer space
|
||||
// minus the bytes already held out of order. Subtracting them prevents the
|
||||
// sender from overrunning the receiver while a gap is open.
|
||||
func (h *Handler) recvWindow() Size {
|
||||
free := Size(h.bufRx.Free())
|
||||
if !h.reasm.enabled() {
|
||||
return free
|
||||
}
|
||||
if ooo := Size(h.reasm.bufferedBytes()); ooo < free {
|
||||
return free - ooo
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AwaitingSynResponse returns true if the Handler is an active client opened with [Handler.OpenActive] and has already sent out the first SYN packet to the remote client.
|
||||
|
||||
@@ -3,6 +3,7 @@ package tcp
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
@@ -1487,3 +1488,103 @@ func TestRetransmit_CumulativeACK_NoSpurious(t *testing.T) {
|
||||
t.Fatalf("spurious retransmission after cumulative ACK: SEQ=%d len=%d (issue #57)", seg.SEQ, seg.DATALEN)
|
||||
}
|
||||
}
|
||||
|
||||
// emitClientData writes payload to the client and emits it as one data packet,
|
||||
// returning a copy of the wire bytes (the caller controls delivery order).
|
||||
func emitClientData(t *testing.T, client *Handler, buf []byte, payload string) []byte {
|
||||
t.Helper()
|
||||
if _, err := client.Write([]byte(payload)); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf)
|
||||
n, err := client.Send(buf)
|
||||
if err != nil {
|
||||
t.Fatal("client send:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected a data segment, got header-only")
|
||||
}
|
||||
return append([]byte(nil), buf[:n]...)
|
||||
}
|
||||
|
||||
// TestHandler_OutOfOrderReassembly drives the full out-of-order path: a later
|
||||
// segment delivered before the gap-filling one is staged, then delivered
|
||||
// contiguously once the gap arrives, without go-back-N.
|
||||
func TestHandler_OutOfOrderReassembly(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
const maxpackets = 4
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
pkt1 := emitClientData(t, client, buf[:], "AAAA") // seq S, covers S..S+4.
|
||||
pkt2 := emitClientData(t, client, buf[:], "BBBB") // seq S+4, covers S+4..S+8.
|
||||
|
||||
full := server.SizeInput()
|
||||
|
||||
// Deliver the second segment first: accepted, buffered, not yet readable.
|
||||
if err := server.Recv(pkt2); err != nil {
|
||||
t.Fatalf("out-of-order segment must be accepted, got: %v", err)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("OOO data must not be readable yet, buffered=%d", server.BufferedInput())
|
||||
}
|
||||
// Advertised window shrinks by the held bytes so the peer cannot overrun.
|
||||
if got := server.FreeInput(); got != full-4 {
|
||||
t.Fatalf("FreeInput=%d, want %d (window reduced by held OOO bytes)", got, full-4)
|
||||
}
|
||||
|
||||
// Deliver the gap-filling first segment: both become contiguous.
|
||||
if err := server.Recv(pkt1); err != nil {
|
||||
t.Fatalf("gap-filling segment: %v", err)
|
||||
}
|
||||
if server.BufferedInput() != 8 {
|
||||
t.Fatalf("buffered=%d, want 8 after gap fill", server.BufferedInput())
|
||||
}
|
||||
if got := server.FreeInput(); got != full-8 {
|
||||
t.Fatalf("FreeInput=%d, want %d after delivery", got, full-8)
|
||||
}
|
||||
|
||||
var rd [16]byte
|
||||
n, err := server.Read(rd[:])
|
||||
if err != nil {
|
||||
t.Fatal("server read:", err)
|
||||
}
|
||||
if string(rd[:n]) != "AAAABBBB" {
|
||||
t.Fatalf("reassembled %q, want AAAABBBB", rd[:n])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_OutOfOrderDiscardedAfterShutdownRead verifies staged segments are
|
||||
// dropped, not delivered, when the read side is shut down before the gap fills.
|
||||
func TestHandler_OutOfOrderDiscardedAfterShutdownRead(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
const maxpackets = 4
|
||||
rng := rand.New(rand.NewSource(100))
|
||||
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
pkt1 := emitClientData(t, client, buf[:], "AAAA")
|
||||
pkt2 := emitClientData(t, client, buf[:], "BBBB")
|
||||
|
||||
if err := server.Recv(pkt2); err != nil { // buffer out of order.
|
||||
t.Fatalf("OOO segment: %v", err)
|
||||
}
|
||||
server.ShutdownRead() // application done reading; staged data must be dropped.
|
||||
|
||||
if err := server.Recv(pkt1); err != nil && !IsDroppedErr(err) {
|
||||
t.Fatalf("gap-filling segment after shutdown: %v", err)
|
||||
}
|
||||
var rd [16]byte
|
||||
n, err := server.Read(rd[:])
|
||||
if n != 0 || err != io.EOF {
|
||||
t.Fatalf("read after ShutdownRead = %d,%v want 0,EOF", n, err)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("discard mode must hold no data, buffered=%d", server.BufferedInput())
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package tcp
|
||||
|
||||
// LossRecovery abstracts TCP packet-loss recovery: RTO, congestion control and
|
||||
// any similar algorithm that observes segment traffic and steers the
|
||||
// connection's transmit behaviour. As far as the tcp package is concerned these
|
||||
// are all the same thing — packet-loss recovery algorithms — so they share one
|
||||
// interface (see discussion #157).
|
||||
//
|
||||
// The tcp package stays free of any time source: the current monotonic time in
|
||||
// nanoseconds (the func() int64 convention used across lneto) is passed in at
|
||||
// each hook boundary. It originates from [ConnConfig.Nanotime] and satisfies the
|
||||
// "WHEN was this segment rx/tx'd" requirement without a clock living inside the
|
||||
// state machine, which also keeps implementations deterministic for testing
|
||||
// (see issue #140).
|
||||
//
|
||||
// The interface is intentionally free of errors: an implementation handles or
|
||||
// reports its own errors rather than propagating them into lneto internals.
|
||||
//
|
||||
// Introspection (smoothed RTT, current window, ...) is deliberately left off the
|
||||
// interface; expose it on the concrete implementation the caller constructs and
|
||||
// hands to [ConnConfig].
|
||||
type LossRecovery interface {
|
||||
// Reset returns the implementation to its initial, pre-connection state. It
|
||||
// is invoked whenever the connection is (re)opened or aborted so a single
|
||||
// LossRecovery value can be reused across the lifetime of connection reuse
|
||||
// (see discussion #115).
|
||||
Reset()
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the
|
||||
// connection must next be serviced by a transmit attempt — typically the RTO
|
||||
// expiry. A return of 0 means there is no pending deadline. It replaces a
|
||||
// poll/atomic-flag scheme with a deadline the caller's event loop can
|
||||
// schedule against.
|
||||
NextDeadline() int64
|
||||
|
||||
// PreRx is called for every segment received on the TCP port before the
|
||||
// state machine processes it, with the monotonic time the segment arrived. It
|
||||
// returns whether the segment should be kept (processed) or dropped.
|
||||
PreRx(incoming Segment, now int64) RxDirective
|
||||
|
||||
// PreTx is called on entering the transmit path (Encapsulate), before a
|
||||
// segment is built, with the current monotonic time. Its directive tells the
|
||||
// connection whether to retransmit unacknowledged data, rewind the send
|
||||
// pointer, or hold back new data.
|
||||
PreTx(now int64) TxDirective
|
||||
|
||||
// PostTx is called on leaving the transmit path with the segment that was
|
||||
// actually emitted and the monotonic time it was sent. This is where segment
|
||||
// timing (for RTT sampling and the retransmission timer) is recorded.
|
||||
PostTx(outgoing Segment, now int64)
|
||||
}
|
||||
|
||||
// TxDirective is returned by [LossRecovery.PreTx] to steer the transmit path.
|
||||
// The zero value directs the connection to proceed normally (send new data if
|
||||
// available, no retransmission).
|
||||
type TxDirective struct {
|
||||
// RewindNXT is the number of sequence-space octets to rewind snd.NXT by
|
||||
// before transmitting, for partial (e.g. selective) retransmission. Zero
|
||||
// means no rewind. It is independent of Retransmit, which rewinds fully to
|
||||
// snd.UNA.
|
||||
// RewindNXT uint32
|
||||
|
||||
// RetransmitAll requests go-back-N retransmission: the connection rewinds
|
||||
// snd.NXT to snd.UNA and resends unacknowledged data from the oldest
|
||||
// sequence number.
|
||||
RetransmitAll bool
|
||||
// HoldNew pauses transmission of new data (for example when the congestion
|
||||
// window is exhausted). Retransmissions already directed by this same
|
||||
// directive still proceed.
|
||||
// HoldNew bool
|
||||
}
|
||||
|
||||
// RxDirective is returned by [LossRecovery.PreRx].
|
||||
//
|
||||
// NOTE: its shape is the minimum viable contract — it mirrors the original
|
||||
// PreRx "keep" boolean from discussion #157 — and is the one element of the
|
||||
// interface not yet fully settled there. It is a struct (rather than a bare
|
||||
// bool) so fields can be added without breaking implementations.
|
||||
type RxDirective struct {
|
||||
// Keep reports whether the received segment should be handed to the state
|
||||
// machine. A false value drops the segment before it is processed.
|
||||
Keep bool
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
)
|
||||
|
||||
// recordingLoss is a test LossRecovery that records every hook invocation and
|
||||
// lets the test steer the directives returned to the Handler. It is the
|
||||
// interface counterpart driven by the Handler under test.
|
||||
type recordingLoss struct {
|
||||
resets int
|
||||
preRx []hookCall
|
||||
preTx []int64
|
||||
postTx []hookCall
|
||||
deadline int64 // value NextDeadline reports back.
|
||||
|
||||
// Directives handed back to the Handler.
|
||||
keep bool // PreRx result. Default true (see newRecordingLoss).
|
||||
tx TxDirective // PreTx result.
|
||||
}
|
||||
|
||||
type hookCall struct {
|
||||
seg Segment
|
||||
now int64
|
||||
}
|
||||
|
||||
func newRecordingLoss() *recordingLoss { return &recordingLoss{keep: true} }
|
||||
|
||||
var _ LossRecovery = (*recordingLoss)(nil)
|
||||
|
||||
func (l *recordingLoss) Reset() { l.resets++ }
|
||||
func (l *recordingLoss) NextDeadline() int64 { return l.deadline }
|
||||
|
||||
func (l *recordingLoss) PreRx(incoming Segment, now int64) RxDirective {
|
||||
l.preRx = append(l.preRx, hookCall{seg: incoming, now: now})
|
||||
return RxDirective{Keep: l.keep}
|
||||
}
|
||||
|
||||
func (l *recordingLoss) PreTx(now int64) TxDirective {
|
||||
l.preTx = append(l.preTx, now)
|
||||
return l.tx
|
||||
}
|
||||
|
||||
func (l *recordingLoss) PostTx(outgoing Segment, now int64) {
|
||||
l.postTx = append(l.postTx, hookCall{seg: outgoing, now: now})
|
||||
}
|
||||
|
||||
// TestLossRecovery_DisabledByDefault verifies the Handler runs normally with no
|
||||
// loss recovery installed: NextDeadline reports no deadline and the transmit/
|
||||
// receive paths never touch a nil LossRecovery.
|
||||
func TestLossRecovery_DisabledByDefault(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
setupClientServer(t, rng, client, server)
|
||||
|
||||
if d := client.NextDeadline(); d != 0 {
|
||||
t.Fatalf("NextDeadline with no loss recovery = %d, want 0", d)
|
||||
}
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:]) // must not panic on nil loss recovery.
|
||||
}
|
||||
|
||||
// TestLossRecovery_HooksInvoked verifies the Handler drives the full hook
|
||||
// contract across a handshake: Reset on open, PreTx+PostTx on every transmit,
|
||||
// PreRx on every receive, each stamped with the configured monotonic clock.
|
||||
func TestLossRecovery_HooksInvoked(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(2))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
const clockNow = 1_000_000
|
||||
client.SetLossRecovery(loss, func() int64 { return clockNow })
|
||||
|
||||
setupClientServer(t, rng, client, server) // OpenActive → reset → Reset().
|
||||
if loss.resets == 0 {
|
||||
t.Fatal("Reset not called on open")
|
||||
}
|
||||
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
// Client emitted SYN and the final ACK: both paths must have hit PreTx/PostTx.
|
||||
if len(loss.preTx) == 0 {
|
||||
t.Fatal("PreTx never called on transmit")
|
||||
}
|
||||
if len(loss.postTx) == 0 {
|
||||
t.Fatal("PostTx never called on transmit")
|
||||
}
|
||||
if len(loss.preTx) != len(loss.postTx) {
|
||||
t.Fatalf("PreTx calls=%d, PostTx calls=%d, want equal", len(loss.preTx), len(loss.postTx))
|
||||
}
|
||||
// Client received the SYN-ACK: PreRx must have seen it.
|
||||
if len(loss.preRx) == 0 {
|
||||
t.Fatal("PreRx never called on receive")
|
||||
}
|
||||
|
||||
// The Handler holds no clock: every hook must be stamped from the supplied
|
||||
// nanotime source.
|
||||
for i, c := range loss.postTx {
|
||||
if c.now != clockNow {
|
||||
t.Fatalf("PostTx[%d].now = %d, want clock %d", i, c.now, clockNow)
|
||||
}
|
||||
}
|
||||
for i, now := range loss.preTx {
|
||||
if now != clockNow {
|
||||
t.Fatalf("PreTx[%d].now = %d, want clock %d", i, now, clockNow)
|
||||
}
|
||||
}
|
||||
for i, c := range loss.preRx {
|
||||
if c.now != clockNow {
|
||||
t.Fatalf("PreRx[%d].now = %d, want clock %d", i, c.now, clockNow)
|
||||
}
|
||||
}
|
||||
|
||||
// PostTx receives the segment actually emitted: the first is the SYN.
|
||||
if !loss.postTx[0].seg.Flags.HasAny(FlagSYN) {
|
||||
t.Fatalf("first PostTx segment flags=%s, want SYN", loss.postTx[0].seg.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_NextDeadlineDelegates verifies NextDeadline is forwarded to
|
||||
// the installed LossRecovery unchanged.
|
||||
func TestLossRecovery_NextDeadlineDelegates(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(3))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
loss.deadline = 4242
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
|
||||
if d := client.NextDeadline(); d != 4242 {
|
||||
t.Fatalf("NextDeadline = %d, want delegated 4242", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_PreRxDropsSegment verifies a PreRx directive of Keep=false
|
||||
// drops the segment before the state machine sees it: the payload is not
|
||||
// buffered and connection state is untouched.
|
||||
func TestLossRecovery_PreRxDropsSegment(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(4))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
server.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:]) // keep=true so handshake completes.
|
||||
|
||||
// Now start dropping everything the server receives.
|
||||
loss.keep = false
|
||||
preRxBefore := len(loss.preRx)
|
||||
|
||||
data := []byte("dropme")
|
||||
if _, err := client.Write(data); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf[:])
|
||||
n, err := client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send:", err)
|
||||
}
|
||||
|
||||
if err := server.Recv(buf[:n]); err != nil {
|
||||
t.Fatalf("dropped segment must return nil, got %v", err)
|
||||
}
|
||||
if len(loss.preRx) != preRxBefore+1 {
|
||||
t.Fatalf("PreRx calls=%d, want %d (segment must reach PreRx)", len(loss.preRx), preRxBefore+1)
|
||||
}
|
||||
if server.BufferedInput() != 0 {
|
||||
t.Fatalf("dropped segment must not be buffered, got %d bytes", server.BufferedInput())
|
||||
}
|
||||
if server.State() != StateEstablished {
|
||||
t.Fatalf("dropped segment must not change state, got %s", server.State())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_PreTxRetransmitAll verifies a PreTx directive of
|
||||
// RetransmitAll drives go-back-N: the Handler rewinds and re-emits already-sent,
|
||||
// unacknowledged data from snd.UNA on the next transmit.
|
||||
func TestLossRecovery_PreTxRetransmitAll(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
rng := rand.New(rand.NewSource(5))
|
||||
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
|
||||
|
||||
loss := newRecordingLoss()
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
setupClientServer(t, rng, client, server)
|
||||
var buf [mtu]byte
|
||||
establish(t, client, server, buf[:])
|
||||
|
||||
// Emit one data segment; server never ACKs, so it stays unacknowledged.
|
||||
data := []byte("payload")
|
||||
if _, err := client.Write(data); err != nil {
|
||||
t.Fatal("client write:", err)
|
||||
}
|
||||
clear(buf[:])
|
||||
n, err := client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send data:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected data segment")
|
||||
}
|
||||
firstSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
|
||||
|
||||
// Direct go-back-N on the next transmit.
|
||||
loss.tx = TxDirective{RetransmitAll: true}
|
||||
clear(buf[:])
|
||||
n, err = client.Send(buf[:])
|
||||
if err != nil {
|
||||
t.Fatal("client send retransmit:", err)
|
||||
}
|
||||
if n <= sizeHeaderTCP {
|
||||
t.Fatal("expected retransmitted data segment")
|
||||
}
|
||||
rtSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
|
||||
|
||||
if rtSeg.SEQ != firstSeg.SEQ {
|
||||
t.Fatalf("retransmit SEQ=%d, want original UNA SEQ=%d (go-back-N)", rtSeg.SEQ, firstSeg.SEQ)
|
||||
}
|
||||
if rtSeg.DATALEN != firstSeg.DATALEN {
|
||||
t.Fatalf("retransmit DATALEN=%d, want %d", rtSeg.DATALEN, firstSeg.DATALEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLossRecovery_ResetOnReopen verifies Reset fires on every (re)open and on
|
||||
// Abort, so a single LossRecovery value can be reused across connection reuse.
|
||||
func TestLossRecovery_ResetOnReopen(t *testing.T) {
|
||||
const mtu = ethernet.MaxMTU
|
||||
client := newHandler(t, mtu, 3)
|
||||
loss := newRecordingLoss()
|
||||
client.SetLossRecovery(loss, func() int64 { return 1 })
|
||||
|
||||
if err := client.OpenActive(1234, 5678, 0); err != nil {
|
||||
t.Fatal("open 1:", err)
|
||||
}
|
||||
afterOpen := loss.resets
|
||||
if afterOpen == 0 {
|
||||
t.Fatal("Reset not called on first open")
|
||||
}
|
||||
|
||||
client.Abort()
|
||||
if loss.resets <= afterOpen {
|
||||
t.Fatalf("Reset not called on Abort: resets=%d, want >%d", loss.resets, afterOpen)
|
||||
}
|
||||
afterAbort := loss.resets
|
||||
|
||||
if err := client.OpenActive(1234, 5678, 0); err != nil {
|
||||
t.Fatal("open 2:", err)
|
||||
}
|
||||
if loss.resets <= afterAbort {
|
||||
t.Fatalf("Reset not called on reopen: resets=%d, want >%d", loss.resets, afterAbort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tcp
|
||||
|
||||
import "github.com/soypat/lneto/internal"
|
||||
|
||||
// maxReasmSegments bounds how many distinct out-of-order segments may be held.
|
||||
// It caps only fixed metadata; payload bytes live in the receive ring, bounded
|
||||
// by its free space. Independent of the transmit queue depth.
|
||||
const maxReasmSegments = 8
|
||||
|
||||
// reassembly holds in-window TCP segments that arrived ahead of the next
|
||||
// expected sequence number, so that once the gap is filled the buffered tail is
|
||||
// delivered without go-back-N. Payloads are staged in the free region of the
|
||||
// Handler receive ring (see [internal.Ring.PeekWrite]); only fixed, reused
|
||||
// metadata lives here, so the data path allocates nothing.
|
||||
//
|
||||
// held is kept ordered by ascending sequence number (oldest to newest), which
|
||||
// lets [reassembly.store] locate insertions and overlaps by neighbour and lets
|
||||
// [reassembly.reassemble] deliver a contiguous prefix and truncate it in one
|
||||
// pass.
|
||||
type reassembly struct {
|
||||
held []reasmSeg
|
||||
}
|
||||
|
||||
// reasmSeg records a held segment by sequence number and payload length. No
|
||||
// buffer offset is kept: the ring write pointer advances in lockstep with
|
||||
// rcv.NXT, so the staged bytes are always where seq implies (see
|
||||
// [reassembly.reassemble]).
|
||||
type reasmSeg struct {
|
||||
seq Value
|
||||
n int
|
||||
}
|
||||
|
||||
// reset (re)configures bounded metadata for up to maxSegs held segments, or
|
||||
// disables reassembly when maxSegs is not positive. Held state is cleared;
|
||||
// metadata capacity persists across connection reopens.
|
||||
func (r *reassembly) reset(maxSegs int) {
|
||||
if maxSegs <= 0 {
|
||||
r.held = nil
|
||||
return
|
||||
}
|
||||
internal.SliceReuse(&r.held, maxSegs)
|
||||
}
|
||||
|
||||
// clear drops all held segments without changing configuration.
|
||||
func (r *reassembly) clear() { r.held = r.held[:0] }
|
||||
|
||||
// enabled reports whether out-of-order buffering is configured.
|
||||
func (r *reassembly) enabled() bool { return cap(r.held) > 0 }
|
||||
|
||||
// buffered reports the number of out-of-order segments currently held.
|
||||
func (r *reassembly) buffered() int { return len(r.held) }
|
||||
|
||||
// bufferedBytes reports the total payload bytes currently held out of order.
|
||||
// The receiver subtracts these from its advertised window so the sender cannot
|
||||
// overrun the space the held segments already consume.
|
||||
func (r *reassembly) bufferedBytes() int {
|
||||
n := 0
|
||||
for i := range r.held {
|
||||
n += r.held[i].n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// store stages payload at the offset it will occupy in rx once the gap from
|
||||
// rcvNxt fills, keeping held ordered by seq. It returns true when held,
|
||||
// including when already held (storing is idempotent), and false when disabled,
|
||||
// the payload is empty, metadata is full, it does not fit rx's free region, or
|
||||
// it overlaps a held segment.
|
||||
func (r *reassembly) store(rx *internal.Ring, rcvNxt, seq Value, payload []byte) bool {
|
||||
if !r.enabled() || len(payload) == 0 || len(r.held) >= cap(r.held) {
|
||||
return false
|
||||
}
|
||||
gap := int(Sizeof(rcvNxt, seq))
|
||||
// Early free-space bail before the ordered-insert scan; strictly cautious,
|
||||
// as PeekWrite re-checks this below.
|
||||
if gap+len(payload) > rx.Free() {
|
||||
return false
|
||||
}
|
||||
// Find the insertion point that keeps held ordered by ascending seq.
|
||||
end := Add(seq, Size(len(payload)))
|
||||
i := 0
|
||||
for i < len(r.held) && r.held[i].seq.LessThan(seq) {
|
||||
i++
|
||||
}
|
||||
if i > 0 { // Overlaps the predecessor?
|
||||
if prev := r.held[i-1]; seq.LessThan(Add(prev.seq, Size(prev.n))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if i < len(r.held) { // Duplicate, or overlaps the successor?
|
||||
if next := r.held[i]; next.seq == seq {
|
||||
return true // already buffered; idempotent.
|
||||
} else if next.seq.LessThan(end) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !rx.PeekWrite(payload, gap) {
|
||||
return false
|
||||
}
|
||||
r.held = append(r.held, reasmSeg{})
|
||||
copy(r.held[i+1:], r.held[i:])
|
||||
r.held[i] = reasmSeg{seq: seq, n: len(payload)}
|
||||
return true
|
||||
}
|
||||
|
||||
// reassemble delivers held segments contiguous with nxt by committing their
|
||||
// staged bytes to rx, and drops any beginning before nxt (stale, or overwritten
|
||||
// by the in-order write that advanced nxt). Because held is ordered, it walks a
|
||||
// leading prefix and truncates once. It returns the bytes delivered; the caller
|
||||
// advances rcv.NXT and ACKs. Delivery stops at the first gap, or if rx is full
|
||||
// (the remainder is delivered on a later call).
|
||||
func (r *reassembly) reassemble(rx *internal.Ring, nxt Value) Size {
|
||||
var delivered Size
|
||||
i := 0
|
||||
for i < len(r.held) {
|
||||
seg := r.held[i]
|
||||
switch {
|
||||
case seg.seq == nxt:
|
||||
if rx.Commit(seg.n) != nil {
|
||||
r.held = append(r.held[:0], r.held[i:]...)
|
||||
return delivered
|
||||
}
|
||||
nxt = Add(nxt, Size(seg.n))
|
||||
delivered += Size(seg.n)
|
||||
i++
|
||||
case seg.seq.LessThan(nxt):
|
||||
i++ // stale/overwritten: drop.
|
||||
default:
|
||||
r.held = append(r.held[:0], r.held[i:]...)
|
||||
return delivered // gap before the next segment.
|
||||
}
|
||||
}
|
||||
r.held = r.held[:0]
|
||||
return delivered
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
func TestReassemblyDisabledByDefault(t *testing.T) {
|
||||
var r reassembly
|
||||
if r.enabled() {
|
||||
t.Fatal("zero-value reassembly must be disabled")
|
||||
}
|
||||
var rx internal.Ring
|
||||
if r.store(&rx, 100, 100, []byte("x")) {
|
||||
t.Error("store must fail when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyStoreAndReassemble(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.enabled() {
|
||||
t.Fatal("reassembly should be enabled after reset")
|
||||
}
|
||||
// Buffer two out-of-order segments (gap at seq 100), stored newest-first to
|
||||
// prove store keeps held ordered by seq.
|
||||
if !r.store(&rx, 100, 108, []byte("CCC")) { // covers 108..111
|
||||
t.Fatal("store 108 failed")
|
||||
}
|
||||
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
|
||||
t.Fatal("store 104 failed")
|
||||
}
|
||||
if r.buffered() != 2 {
|
||||
t.Fatalf("buffered=%d, want 2", r.buffered())
|
||||
}
|
||||
if r.held[0].seq != 104 || r.held[1].seq != 108 {
|
||||
t.Fatalf("held not ordered by seq: %+v", r.held)
|
||||
}
|
||||
// A gap at 100 blocks delivery entirely.
|
||||
if got := r.reassemble(&rx, 100); got != 0 {
|
||||
t.Fatalf("reassemble(100)=%d, want 0 with a gap at 100", got)
|
||||
}
|
||||
// Once 100..104 is delivered in order, both held segments become contiguous.
|
||||
if _, err := rx.Write([]byte("AAAA")); err != nil {
|
||||
t.Fatal("gap write:", err)
|
||||
}
|
||||
if got := r.reassemble(&rx, 104); got != 7 {
|
||||
t.Fatalf("reassemble(104)=%d, want 7", got)
|
||||
}
|
||||
if r.buffered() != 0 {
|
||||
t.Errorf("buffered=%d, want 0 after full delivery", r.buffered())
|
||||
}
|
||||
out := make([]byte, 16)
|
||||
n, _ := rx.Read(out)
|
||||
if !bytes.Equal(out[:n], []byte("AAAABBBBCCC")) {
|
||||
t.Fatalf("reassembled %q, want AAAABBBBCCC", out[:n])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyDedup(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("AAA")) {
|
||||
t.Fatal("first store failed")
|
||||
}
|
||||
if !r.store(&rx, 100, 104, []byte("AAA")) {
|
||||
t.Error("duplicate store of same seq should be idempotent true")
|
||||
}
|
||||
if r.buffered() != 1 {
|
||||
t.Errorf("buffered=%d, want 1 (duplicate must not add a segment)", r.buffered())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyFull(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(2) // only 2 slots.
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("a")) || !r.store(&rx, 100, 108, []byte("b")) {
|
||||
t.Fatal("filling slots failed")
|
||||
}
|
||||
if r.store(&rx, 100, 116, []byte("c")) {
|
||||
t.Error("store must fail when all slots are occupied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyOversizedRejected(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(2)
|
||||
rx := internal.Ring{Buf: make([]byte, 4)}
|
||||
if r.store(&rx, 100, 104, []byte("toolong")) {
|
||||
t.Error("payload larger than free receive space must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyOverlapRejected(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
|
||||
t.Fatal("store 104 failed")
|
||||
}
|
||||
// Segments overlapping the held 104..108 region must be rejected.
|
||||
if r.store(&rx, 100, 106, []byte("XX")) { // 106..108 overlaps its successor
|
||||
t.Error("overlapping store must be rejected")
|
||||
}
|
||||
if r.store(&rx, 100, 102, []byte("YYYY")) { // 102..106 overlaps its predecessor
|
||||
t.Error("overlapping store must be rejected")
|
||||
}
|
||||
if r.buffered() != 1 {
|
||||
t.Errorf("buffered=%d, want 1 (overlaps must not be stored)", r.buffered())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassembleDropsStale checks segments beginning before nxt are dropped, not
|
||||
// delivered (their staged bytes may have been overwritten by the in-order write).
|
||||
func TestReassembleDropsStale(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
r.store(&rx, 100, 104, []byte("BBBB")) // covers 104..108
|
||||
r.store(&rx, 100, 112, []byte("DDDD")) // covers 112..116
|
||||
// rcv.NXT advanced to 106, partway into the first held segment, with a gap
|
||||
// before the second: the stale segment is dropped and nothing is delivered.
|
||||
if got := r.reassemble(&rx, 106); got != 0 {
|
||||
t.Errorf("reassemble(106)=%d, want 0", got)
|
||||
}
|
||||
if r.buffered() != 1 || r.held[0].seq != 112 {
|
||||
t.Errorf("held=%+v, want only seq 112", r.held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassemblyResetDisables(t *testing.T) {
|
||||
var r reassembly
|
||||
rx := internal.Ring{Buf: make([]byte, 16)}
|
||||
r.reset(4)
|
||||
r.store(&rx, 100, 104, []byte("a"))
|
||||
r.reset(0)
|
||||
if r.enabled() {
|
||||
t.Error("reset(0) must disable reassembly")
|
||||
}
|
||||
if r.buffered() != 0 {
|
||||
t.Error("reset must clear held segments")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReassembly_noAllocs verifies the data-path operations allocate nothing
|
||||
// once configured (metadata is bounded at reset, payloads reuse the ring).
|
||||
func TestReassembly_noAllocs(t *testing.T) {
|
||||
var r reassembly
|
||||
r.reset(4)
|
||||
rx := internal.Ring{Buf: make([]byte, 32)}
|
||||
seg := []byte("DATA")
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
r.clear()
|
||||
rx.Reset()
|
||||
r.store(&rx, 100, 108, seg) // buffer out of order.
|
||||
r.store(&rx, 100, 104, seg) // buffer out of order.
|
||||
_ = r.bufferedBytes()
|
||||
_ = r.reassemble(&rx, 112)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("reassembly data path must not allocate, got %v allocs/op", allocs)
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package tcp
|
||||
|
||||
import "time"
|
||||
|
||||
// RFC 6298 retransmission-timeout (RTO) parameters. The algorithm keeps a
|
||||
// single retransmission timer per connection (RFC 6298 §5): the timer is
|
||||
// (re)started whenever new data is acknowledged while data remains in flight,
|
||||
// stopped when all data is acknowledged, and on expiry the oldest unacknowledged
|
||||
// segment is retransmitted and the RTO is doubled (exponential backoff, §5.5).
|
||||
const (
|
||||
// rtoInitial is the RTO used before the first RTT measurement (RFC 6298 §2.1).
|
||||
rtoInitial = time.Second
|
||||
// rtoMin clamps the lower bound of the RTO. RFC 6298 §2.4 recommends a
|
||||
// minimum of 1s, but that is punishing on the low-latency links lneto
|
||||
// targets; like Linux we use a smaller floor so recovery on LAN/embedded
|
||||
// links is timely.
|
||||
rtoMin = 200 * time.Millisecond
|
||||
// rtoMax clamps the upper bound across exponential backoff (RFC 6298 §5.5
|
||||
// permits a maximum of at least 60s).
|
||||
rtoMax = 60 * time.Second
|
||||
|
||||
// rttGainShift (alpha = 1/8) and rttvarGainShift (beta = 1/4) are the
|
||||
// smoothing gains of RFC 6298 §2.3, applied as integer shifts.
|
||||
rttGainShift = 3 // alpha = 1/8
|
||||
rttvarGainShift = 2 // beta = 1/4
|
||||
// rttvarK is the RTTVAR multiplier in RTO = SRTT + K*RTTVAR (RFC 6298 §2.3).
|
||||
rttvarK = 4
|
||||
// backoffMax caps the exponential-backoff doublings so RTO arithmetic cannot
|
||||
// overflow and a wedged connection keeps probing at rtoMax.
|
||||
backoffMax = 12
|
||||
)
|
||||
|
||||
// RTO implements the RFC 6298 round-trip-time estimator and the single
|
||||
// retransmission timer as a [LossRecovery]. Construct it with new(RTO) and hand
|
||||
// it to [ConnConfig.LossRecovery]; the connection calls [RTO.Reset] on open, so
|
||||
// the zero value is ready to use.
|
||||
//
|
||||
// RTO is a pure, reactive state machine: it observes the segments a connection
|
||||
// sends and receives (via the LossRecovery hooks) and the monotonic time handed
|
||||
// in at each hook, and from those alone derives RTT estimates and retransmission
|
||||
// decisions. It holds no clock and allocates nothing, which keeps it
|
||||
// deterministic for unit testing (see issue #140).
|
||||
//
|
||||
// RTO tracks its own shadow of the send sequence space purely from the segments
|
||||
// it observes: [RTO.PostTx] advances the highest sequence sent and [RTO.PreRx]
|
||||
// advances the highest sequence acknowledged. This is what lets it manage the
|
||||
// timer (RFC 6298 §5.2/§5.3) without reaching into the tcp state machine, and it
|
||||
// is also how retransmissions are distinguished for Karn's algorithm — a segment
|
||||
// whose sequence space is not beyond the shadow snd.NXT is a retransmission and
|
||||
// is never RTT-sampled.
|
||||
type RTO struct {
|
||||
srtt time.Duration // smoothed round-trip time (SRTT).
|
||||
rttvar time.Duration // round-trip-time variation (RTTVAR).
|
||||
rto time.Duration // current retransmission timeout.
|
||||
haveRTT bool // false until the first RTT sample is taken.
|
||||
|
||||
// Shadow of the send sequence space, derived from observed segments.
|
||||
haveSeq bool // false until the first data segment is observed.
|
||||
sndUNA Value // highest acknowledged sequence number seen on the wire.
|
||||
sndNXT Value // one past the highest sequence number sent.
|
||||
|
||||
// RTT sampling state (Karn's algorithm, RFC 6298 §3): at most one segment is
|
||||
// timed at a time and retransmitted segments are never sampled.
|
||||
timing bool
|
||||
timedSeq Value // ACK at or beyond this value completes the sample.
|
||||
timedAt int64 // send time (monotonic ns) of the timed segment.
|
||||
|
||||
// Retransmission timer state.
|
||||
running bool
|
||||
deadline int64 // time (monotonic ns) at which the timer expires.
|
||||
backoff uint8 // consecutive timeouts, for exponential backoff.
|
||||
}
|
||||
|
||||
var _ LossRecovery = (*RTO)(nil)
|
||||
|
||||
// Reset returns the estimator to its pre-connection state with the initial RTO.
|
||||
// It implements [LossRecovery] and is called when the connection opens or aborts
|
||||
// so the estimator can be reused across connection reuse.
|
||||
func (r *RTO) Reset() { *r = RTO{rto: rtoInitial} }
|
||||
|
||||
// SmoothedRTT returns the current smoothed round-trip time (SRTT), or zero
|
||||
// before the first RTT measurement. It is concrete-type introspection and is
|
||||
// intentionally not part of [LossRecovery].
|
||||
func (r *RTO) SmoothedRTT() time.Duration { return r.srtt }
|
||||
|
||||
// CurrentRTO returns the timeout currently in effect, clamped to [rtoMin, rtoMax].
|
||||
func (r *RTO) CurrentRTO() time.Duration {
|
||||
rto := r.rto
|
||||
if rto < rtoMin {
|
||||
rto = rtoMin
|
||||
} else if rto > rtoMax {
|
||||
rto = rtoMax
|
||||
}
|
||||
return rto
|
||||
}
|
||||
|
||||
// Running reports whether the retransmission timer is currently armed.
|
||||
func (r *RTO) Running() bool { return r.running }
|
||||
|
||||
// NextDeadline returns the monotonic-nanosecond instant at which the timer
|
||||
// expires, or 0 when it is not armed. It implements [LossRecovery].
|
||||
func (r *RTO) NextDeadline() int64 {
|
||||
if !r.running {
|
||||
return 0
|
||||
}
|
||||
return r.deadline
|
||||
}
|
||||
|
||||
// PreRx samples the RTT and manages the retransmission timer from a received
|
||||
// segment (RFC 6298 §5.2/§5.3). It implements [LossRecovery] and always keeps
|
||||
// the segment (the estimator never drops traffic).
|
||||
func (r *RTO) PreRx(incoming Segment, now int64) RxDirective {
|
||||
if !r.haveSeq || !incoming.Flags.HasAny(FlagACK) {
|
||||
return RxDirective{Keep: true}
|
||||
}
|
||||
ack := incoming.ACK
|
||||
if r.timing && !ack.LessThan(r.timedSeq) {
|
||||
// ACK covers the timed segment: take the RTT sample (§4). A valid
|
||||
// measurement collapses the backoff (§5.7).
|
||||
r.updateRTT(time.Duration(now - r.timedAt))
|
||||
r.timing = false
|
||||
r.backoff = 0
|
||||
}
|
||||
if r.sndUNA.LessThan(ack) && !r.sndNXT.LessThan(ack) {
|
||||
// ACK advances snd.UNA and does not exceed what we have sent.
|
||||
r.sndUNA = ack
|
||||
}
|
||||
if r.sndUNA == r.sndNXT {
|
||||
r.running = false // §5.3: all outstanding data acknowledged.
|
||||
} else {
|
||||
// §5.3: new (but not all) data acknowledged — restart the timer.
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
}
|
||||
return RxDirective{Keep: true}
|
||||
}
|
||||
|
||||
// PreTx reports whether the retransmission timer has expired and, if so, applies
|
||||
// the RFC 6298 §5.4–§5.6 timeout response — discard the outstanding RTT sample
|
||||
// (Karn), back the RTO off exponentially and restart the timer — returning a
|
||||
// directive that asks the connection to retransmit from snd.UNA (go-back-N). It
|
||||
// implements [LossRecovery].
|
||||
func (r *RTO) PreTx(now int64) TxDirective {
|
||||
if !r.running || now < r.deadline || r.sndUNA == r.sndNXT {
|
||||
return TxDirective{}
|
||||
}
|
||||
r.timing = false // §5.4: do not sample a retransmitted segment.
|
||||
if r.backoff < backoffMax {
|
||||
r.backoff++
|
||||
r.rto = min(r.CurrentRTO()*2, rtoMax) // §5.5: RTO = RTO * 2.
|
||||
}
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
return TxDirective{RetransmitAll: true}
|
||||
}
|
||||
|
||||
// PostTx records an emitted segment: it advances the shadow send sequence,
|
||||
// begins timing newly transmitted data (RFC 6298 §3) and arms the timer (§5.1).
|
||||
// Segments that do not extend the send sequence are retransmissions and are
|
||||
// never RTT-sampled (Karn's algorithm). Control-only segments (no data) are
|
||||
// ignored. It implements [LossRecovery].
|
||||
func (r *RTO) PostTx(outgoing Segment, now int64) {
|
||||
if outgoing.DATALEN == 0 {
|
||||
return // only data segments are timed / arm the RTO.
|
||||
}
|
||||
segStart := outgoing.SEQ
|
||||
segEnd := segStart + Value(outgoing.LEN())
|
||||
if !r.haveSeq {
|
||||
r.haveSeq = true
|
||||
r.sndUNA = segStart
|
||||
r.sndNXT = segStart
|
||||
}
|
||||
if !r.sndNXT.LessThan(segEnd) {
|
||||
// Segment does not extend the send sequence: it is a retransmission.
|
||||
// Discard any outstanding RTT sample per Karn's algorithm. The timer was
|
||||
// already (re)armed by PreTx on the timeout that triggered this resend.
|
||||
r.timing = false
|
||||
return
|
||||
}
|
||||
r.sndNXT = segEnd
|
||||
if !r.timing {
|
||||
r.timing = true
|
||||
r.timedSeq = segEnd
|
||||
r.timedAt = now
|
||||
}
|
||||
if !r.running {
|
||||
r.running = true
|
||||
r.deadline = now + int64(r.CurrentRTO())
|
||||
}
|
||||
}
|
||||
|
||||
// updateRTT folds a round-trip measurement into SRTT/RTTVAR/RTO using the
|
||||
// integer-shift form of RFC 6298 §2.2/§2.3.
|
||||
func (r *RTO) updateRTT(sample time.Duration) {
|
||||
if sample <= 0 {
|
||||
return
|
||||
}
|
||||
if !r.haveRTT {
|
||||
// First measurement (RFC 6298 §2.2).
|
||||
r.srtt = sample
|
||||
r.rttvar = sample / 2
|
||||
r.haveRTT = true
|
||||
} else {
|
||||
// Subsequent measurements (RFC 6298 §2.3):
|
||||
// RTTVAR = (1-beta)*RTTVAR + beta*|SRTT-R|
|
||||
// SRTT = (1-alpha)*SRTT + alpha*R
|
||||
diff := r.srtt - sample
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
r.rttvar += (diff - r.rttvar) >> rttvarGainShift
|
||||
r.srtt += (sample - r.srtt) >> rttGainShift
|
||||
}
|
||||
r.rto = r.srtt + rttvarK*r.rttvar
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package tcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const rtoMs = int64(time.Millisecond)
|
||||
|
||||
// rtoDataSeg builds a data segment of datalen octets starting at seq.
|
||||
func rtoDataSeg(seq uint32, datalen int) Segment {
|
||||
return Segment{SEQ: Value(seq), DATALEN: Size(datalen), Flags: FlagPSH | FlagACK}
|
||||
}
|
||||
|
||||
// rtoAckSeg builds a bare ACK acknowledging up to ack.
|
||||
func rtoAckSeg(ack uint32) Segment {
|
||||
return Segment{ACK: Value(ack), Flags: FlagACK}
|
||||
}
|
||||
|
||||
func newRTO() *RTO {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
return &r
|
||||
}
|
||||
|
||||
func TestRTO_Reset(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
if r.rto != rtoInitial {
|
||||
t.Errorf("initial rto=%v, want %v", r.rto, rtoInitial)
|
||||
}
|
||||
if r.CurrentRTO() != rtoInitial {
|
||||
t.Errorf("CurrentRTO=%v, want %v", r.CurrentRTO(), rtoInitial)
|
||||
}
|
||||
if r.haveRTT {
|
||||
t.Error("haveRTT should be false before first sample")
|
||||
}
|
||||
if r.Running() || r.NextDeadline() != 0 {
|
||||
t.Error("timer must be disarmed after Reset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_ArmOnSendSampleOnAck sends data, verifies the timer arms, then acks it
|
||||
// and verifies an RTT sample is taken and the timer stops once all data is acked.
|
||||
func TestRTO_ArmOnSendSampleOnAck(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
if !r.Running() {
|
||||
t.Fatal("timer must arm after sending data")
|
||||
}
|
||||
if r.NextDeadline() != int64(rtoInitial) {
|
||||
t.Errorf("deadline=%d, want %d", r.NextDeadline(), int64(rtoInitial))
|
||||
}
|
||||
|
||||
// ACK arrives one RTT (40ms) later covering all sent data.
|
||||
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs)
|
||||
if !dir.Keep {
|
||||
t.Error("PreRx must keep the segment")
|
||||
}
|
||||
if r.Running() {
|
||||
t.Error("timer must stop once all data is acknowledged")
|
||||
}
|
||||
if r.SmoothedRTT() != 40*time.Millisecond {
|
||||
t.Errorf("srtt=%v, want 40ms", r.SmoothedRTT())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_RetransmitOnTimeout verifies PreTx directs a go-back-N retransmit once
|
||||
// the deadline passes with data outstanding, and backs the RTO off.
|
||||
func TestRTO_RetransmitOnTimeout(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
|
||||
if r.PreTx(int64(rtoInitial) - 1).RetransmitAll {
|
||||
t.Fatal("must not retransmit before the deadline")
|
||||
}
|
||||
dir := r.PreTx(int64(rtoInitial))
|
||||
if !dir.RetransmitAll {
|
||||
t.Fatal("RTO must fire at the deadline with data outstanding")
|
||||
}
|
||||
if r.CurrentRTO() != 2*rtoInitial {
|
||||
t.Errorf("rto=%v after one backoff, want %v", r.CurrentRTO(), 2*rtoInitial)
|
||||
}
|
||||
// The connection resends from snd.UNA; PostTx sees a retransmission.
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
|
||||
if r.timing {
|
||||
t.Error("retransmitted segment must not be RTT-sampled (Karn)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_KarnNoSampleOnRetransmittedAck verifies that after a retransmission the
|
||||
// ACK does not produce an RTT sample (Karn's algorithm).
|
||||
func TestRTO_KarnNoSampleOnRetransmittedAck(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
// Timeout and retransmit.
|
||||
r.PreTx(int64(rtoInitial))
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
|
||||
// ACK now arrives; no sample should be taken since timing was discarded.
|
||||
r.PreRx(rtoAckSeg(iss+100), int64(rtoInitial)+10*rtoMs)
|
||||
if r.haveRTT {
|
||||
t.Error("no RTT sample should exist after a retransmission (Karn)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_TimerRestartsWhilePartiallyAcked verifies the timer restarts (not
|
||||
// stops) when an ACK advances UNA but data remains in flight (RFC 6298 §5.3).
|
||||
func TestRTO_TimerRestartsWhilePartiallyAcked(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
r.PostTx(rtoDataSeg(iss+100, 100), 0) // 200 octets outstanding, iss..iss+200.
|
||||
|
||||
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs) // acks first 100 only.
|
||||
if !r.Running() {
|
||||
t.Fatal("timer must remain armed while data is still in flight")
|
||||
}
|
||||
if r.NextDeadline() != 40*rtoMs+int64(r.CurrentRTO()) {
|
||||
t.Errorf("deadline=%d, want %d", r.NextDeadline(), 40*rtoMs+int64(r.CurrentRTO()))
|
||||
}
|
||||
if !dir.Keep {
|
||||
t.Error("PreRx must keep the segment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_NoArmWithoutData verifies control-only segments neither arm the timer
|
||||
// nor start an RTT sample.
|
||||
func TestRTO_NoArmWithoutData(t *testing.T) {
|
||||
r := newRTO()
|
||||
r.PostTx(Segment{SEQ: 1000, Flags: FlagACK}, 0) // pure ACK, DATALEN==0.
|
||||
if r.Running() || r.timing {
|
||||
t.Error("pure control segment must not arm the timer or start a sample")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_BackoffCollapsesOnValidSample verifies a valid RTT measurement
|
||||
// collapses the exponential backoff counter (RFC 6298 §5.7).
|
||||
func TestRTO_BackoffCollapsesOnValidSample(t *testing.T) {
|
||||
r := newRTO()
|
||||
const iss = uint32(1000)
|
||||
r.PostTx(rtoDataSeg(iss, 100), 0)
|
||||
r.PreTx(int64(rtoInitial)) // one timeout: backoff=1.
|
||||
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial)) // retransmit (no sample).
|
||||
if r.backoff != 1 {
|
||||
t.Fatalf("backoff=%d, want 1 after a timeout", r.backoff)
|
||||
}
|
||||
// New data sent and freshly sampled, then acked.
|
||||
r.PostTx(rtoDataSeg(iss+100, 100), int64(rtoInitial)+rtoMs)
|
||||
r.PreRx(rtoAckSeg(iss+200), int64(rtoInitial)+30*rtoMs)
|
||||
if r.backoff != 0 {
|
||||
t.Errorf("backoff=%d, want 0 after a valid RTT sample", r.backoff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_Clamped verifies CurrentRTO is clamped to [rtoMin, rtoMax].
|
||||
func TestRTO_Clamped(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
r.rto = time.Nanosecond
|
||||
if got := r.CurrentRTO(); got != rtoMin {
|
||||
t.Errorf("CurrentRTO=%v, want floor %v", got, rtoMin)
|
||||
}
|
||||
r.rto = time.Hour
|
||||
if got := r.CurrentRTO(); got != rtoMax {
|
||||
t.Errorf("CurrentRTO=%v, want ceiling %v", got, rtoMax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_UpdateRTTFirstSample verifies the first-measurement initialization of
|
||||
// SRTT/RTTVAR (RFC 6298 §2.2).
|
||||
func TestRTO_UpdateRTTFirstSample(t *testing.T) {
|
||||
var r RTO
|
||||
r.Reset()
|
||||
r.updateRTT(100 * time.Millisecond)
|
||||
if r.srtt != 100*time.Millisecond {
|
||||
t.Errorf("srtt=%v, want 100ms", r.srtt)
|
||||
}
|
||||
if r.rttvar != 50*time.Millisecond {
|
||||
t.Errorf("rttvar=%v, want 50ms", r.rttvar)
|
||||
}
|
||||
// RTO = SRTT + K*RTTVAR = 100 + 4*50 = 300ms.
|
||||
if r.rto != 300*time.Millisecond {
|
||||
t.Errorf("rto=%v, want 300ms", r.rto)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTO_ImplementsLossRecovery exercises RTO through the [LossRecovery]
|
||||
// interface: sending data arms a deadline and a full ACK disarms it.
|
||||
func TestRTO_ImplementsLossRecovery(t *testing.T) {
|
||||
var lr LossRecovery = newRTO()
|
||||
lr.Reset()
|
||||
lr.PostTx(rtoDataSeg(1000, 100), 0)
|
||||
if lr.NextDeadline() == 0 {
|
||||
t.Error("expected an armed deadline after sending data")
|
||||
}
|
||||
if !lr.PreRx(rtoAckSeg(1100), 10*rtoMs).Keep {
|
||||
t.Error("PreRx must keep")
|
||||
}
|
||||
if lr.NextDeadline() != 0 {
|
||||
t.Error("expected disarmed timer after full ack")
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -26,10 +26,11 @@ const _State_name = "CLOSEDLISTENSYN-RECEIVEDSYN-SENTESTABLISHEDFIN-WAIT-1FIN-WA
|
||||
var _State_index = [...]uint8{0, 6, 12, 24, 32, 43, 53, 63, 70, 79, 89, 97}
|
||||
|
||||
func (i State) String() string {
|
||||
if i >= State(len(_State_index)-1) {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_State_index)-1 {
|
||||
return "State(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _State_name[_State_index[i]:_State_index[i+1]]
|
||||
return _State_name[_State_index[idx]:_State_index[idx+1]]
|
||||
}
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
|
||||
@@ -227,7 +227,7 @@ func BenchmarkSYNCookie_Generate(b *testing.B) {
|
||||
clientISN := Value(0x12345678)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN)
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func BenchmarkSYNCookie_Validate(b *testing.B) {
|
||||
ackNum := cookie + 1
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package netdev
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
// TODO(soypat): True Zero Copy (TZC)
|
||||
// TODO(soypat): TZC acheived on redesigning [DevEthernet] to not own any buffers and ask the networking stack for buffers in the callback path. TZC already acheived for Polling path.
|
||||
// TODO(soypat): TZC in callback path requires redesign of bufferSelect and [Runner] likely.
|
||||
|
||||
// lenClaimed marks a slot claimed by putRx before its frame copy completes.
|
||||
// The slot is published by storing the frame length, which must be the
|
||||
// claimant's last write so getRx never observes a partially copied frame.
|
||||
const lenClaimed = -1
|
||||
|
||||
// bufferSelect is a fixed pool of frame buffers shared between the runner
|
||||
// goroutine and the device's receive handler goroutine. Slot ownership is
|
||||
// arbitrated exclusively through CAS on lenAcquire:
|
||||
// - 0: slot free.
|
||||
// - lenClaimed(<0): slot claimed by putRx, frame copy in progress.
|
||||
// - n>0: slot owned; if isRx is set the slot holds a published Rx frame.
|
||||
//
|
||||
// Only goroPutRx may be called concurrently with the other methods; all other
|
||||
// methods must be called from a single goroutine (the runner's).
|
||||
type bufferSelect struct {
|
||||
// nextSeq generates arrival-order sequence numbers for Rx frames so getRx
|
||||
// yields frames in the order they were received, not in slot order.
|
||||
nextSeq atomic.Uint32
|
||||
missedAcquire atomic.Uint32
|
||||
bufs []struct {
|
||||
lenAcquire atomic.Int32
|
||||
isRx atomic.Bool
|
||||
seq uint32
|
||||
buf []byte
|
||||
}
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) reset(bufs [][]byte) {
|
||||
internal.SliceReuse(&bs.bufs, len(bufs))
|
||||
bs.bufs = bs.bufs[:len(bufs)]
|
||||
for i := range bs.bufs {
|
||||
bs.bufs[i].buf = bufs[i]
|
||||
}
|
||||
bs.releaseAll()
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) releaseAll() {
|
||||
bs.nextSeq.Store(0)
|
||||
bs.missedAcquire.Store(0)
|
||||
for i := range bs.bufs {
|
||||
bs.bufs[i].isRx.Store(false)
|
||||
bs.bufs[i].lenAcquire.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// acquire claims a free slot for exclusive use by the caller and returns it
|
||||
// sized to len. Returns nil if no slot is free or len exceeds slot size.
|
||||
func (bs *bufferSelect) acquire(len int) []byte {
|
||||
if len == 0 {
|
||||
bs.missedAcquire.Add(1)
|
||||
return nil
|
||||
}
|
||||
for i := range bs.bufs {
|
||||
if len > cap(bs.bufs[i].buf) {
|
||||
break // Length too long, would need allocation.
|
||||
}
|
||||
if bs.bufs[i].lenAcquire.CompareAndSwap(0, int32(len)) {
|
||||
return bs.bufs[i].buf[:len]
|
||||
}
|
||||
}
|
||||
bs.missedAcquire.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// goroPutRx copies an incoming frame into a free slot and publishes it for getRx.
|
||||
// It is the only method safe to call concurrently with the runner goroutine.
|
||||
// Returns false if the frame is empty, oversize or no slot is free.
|
||||
func (bs *bufferSelect) goroPutRx(frame []byte) bool {
|
||||
n := len(frame)
|
||||
if n == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range bs.bufs {
|
||||
if n > cap(bs.bufs[i].buf) {
|
||||
return false
|
||||
}
|
||||
if bs.bufs[i].lenAcquire.CompareAndSwap(0, lenClaimed) {
|
||||
bs.bufs[i].isRx.Store(true)
|
||||
bs.bufs[i].seq = bs.nextSeq.Add(1)
|
||||
copy(bs.bufs[i].buf, frame)
|
||||
bs.bufs[i].lenAcquire.Store(int32(n)) // publish: must be last write.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) numFree() (numFree int) {
|
||||
for i := range bs.bufs {
|
||||
if bs.bufs[i].lenAcquire.Load() == 0 {
|
||||
numFree++
|
||||
}
|
||||
}
|
||||
return numFree
|
||||
}
|
||||
|
||||
// getRx returns the oldest published Rx frame, or nil if none is pending.
|
||||
func (bs *bufferSelect) getRx() []byte {
|
||||
oldest := -1
|
||||
var oldestSeq uint32
|
||||
for i := range bs.bufs {
|
||||
n := bs.bufs[i].lenAcquire.Load()
|
||||
if n > 0 && bs.bufs[i].isRx.Load() &&
|
||||
(oldest < 0 || lessThan(bs.bufs[i].seq, oldestSeq)) {
|
||||
oldest = i
|
||||
oldestSeq = bs.bufs[i].seq
|
||||
}
|
||||
}
|
||||
if oldest < 0 {
|
||||
return nil
|
||||
}
|
||||
return bs.bufs[oldest].buf[:bs.bufs[oldest].lenAcquire.Load()]
|
||||
}
|
||||
|
||||
func (bs *bufferSelect) release(buf []byte) {
|
||||
ptr := &buf[0]
|
||||
for i := range bs.bufs {
|
||||
if &bs.bufs[i].buf[0] == ptr {
|
||||
// Clear isRx while still owning the slot so the next claimant
|
||||
// never inherits a stale Rx mark.
|
||||
bs.bufs[i].isRx.Store(false)
|
||||
len := bs.bufs[i].lenAcquire.Load()
|
||||
if len > 0 && bs.bufs[i].lenAcquire.CompareAndSwap(len, 0) {
|
||||
return
|
||||
}
|
||||
panic("bs:race to release")
|
||||
}
|
||||
}
|
||||
panic("bs:buffer not exist or bad offset")
|
||||
}
|
||||
|
||||
func lessThan(aIsLessThan, b uint32) bool {
|
||||
return int32(aIsLessThan-b) < 0
|
||||
}
|
||||
+38
-19
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
@@ -30,42 +31,48 @@ type DevEthernet interface {
|
||||
// HardwareAddr6 returns the device's 6-byte MAC address.
|
||||
// For PHY-only devices, returns the MAC provided at configuration.
|
||||
HardwareAddr6() ([6]byte, error)
|
||||
// SendEthFrameOffset transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
|
||||
// SendOffsetEthFrame transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
|
||||
// The frame includes the Ethernet header but NOT the FCS/CRC
|
||||
// trailer (device or stack handles CRC as appropriate).
|
||||
// SendEthFrameOffset blocks until the transmission is queued succesfully
|
||||
// SendOffsetEthFrame blocks until the transmission is queued succesfully
|
||||
// or finished sending. Should not be called concurrently
|
||||
// unless user is sure the driver supports it.
|
||||
SendOffsetEthFrame(offsetTxEthFrame []byte) error
|
||||
// SetRecvHandler registers the function called when an Ethernet
|
||||
// SetEthRecvHandler registers the function called when an Ethernet
|
||||
// frame is received. Buffers needed by the device to operate efficiently
|
||||
// should be allocated on its side. This function is mutually exclusive with EthPoll:
|
||||
// use on or the other to receive data.
|
||||
// should be allocated on its side.
|
||||
//
|
||||
// Frames may be delivered via this handler, via EthPoll's buffer, or both:
|
||||
// - Handler unset: EthPoll writes received frames into its argument buffer.
|
||||
// - Handler set: received frames are delivered to the handler. EthPoll must
|
||||
// not write to its argument buffer; it is called with a nil buffer purely
|
||||
// to pump devices that need explicit servicing to drive the handler.
|
||||
//
|
||||
// Quiescence guarantee: SetEthRecvHandler(nil) must not return while a
|
||||
// previously installed handler is executing on another goroutine, and after
|
||||
// it returns the old handler must not be invoked again (analogous to Linux
|
||||
// synchronize_irq semantics). Callers rely on this to safely reuse the
|
||||
// buffers a handler writes into.
|
||||
SetEthRecvHandler(handler func(rxEthframe []byte))
|
||||
// EthPoll services the device. For poll-based devices (e.g. CYW43439
|
||||
// over SPI), reads from the bus and invokes the handler for each
|
||||
// received frame. This method is mutually exclusive with SetEthRecvHandler:
|
||||
// use one or the other to receive data but not return data via both channels.
|
||||
// received frame.
|
||||
//
|
||||
// Behavior depends on whether a handler is set via SetEthRecvHandler:
|
||||
// - No handler: writes a received frame into buf, returning its offset/length.
|
||||
// - Handler set: buf is nil and must not be written to; EthPoll only pumps
|
||||
// the device so frames are delivered through the handler. Return values are ignored.
|
||||
EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error)
|
||||
// MaxFrameSizeAndOffset returns the max complete device frame size
|
||||
// (including headers and any overhead) for buffer allocation.
|
||||
// (including headers and any overhead) for Ethernet Rx buffer allocation.
|
||||
// The second value returned is the offset at which the ethernet frame
|
||||
// should be stored when being passed to [DevEthernet.SendOffsetEthFrame].
|
||||
// Buffers allocated should be maxEthernetFrameSize+frameOff where maxEthernetFrameSize
|
||||
// is usually 1500 but less or equal to maxFrameSize-frameOff.
|
||||
// MTU can be calculated doing:
|
||||
// // mfu-(14+4+4) for:
|
||||
// // ethernet header+ethernet CRC if present+ethernet VLAN overhead for VLAN support.
|
||||
// mtu := dev.MaxFrameSizeAndOffset() - ethernet.MaxOverheadSize
|
||||
MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int)
|
||||
// Buffers allocated for Rx should be maxFrameSize.
|
||||
MaxFrameSizeAndOffset() (maxFrameSize int, sendEthFrameOff int)
|
||||
}
|
||||
|
||||
// Stack is an abstraction for a networking stack.
|
||||
type Stack interface {
|
||||
// Configure configures this Stack with the argument mac, ip and gateway addresses.
|
||||
// The Stack must resolve the gateway hardware address if set.
|
||||
// Configure(mac net.HardwareAddr, ip netip.Prefix, gw netip.Addr) error
|
||||
|
||||
// EnableICMP enables responding/sending ICMP echo frames.
|
||||
EnableICMP(enabled bool) error
|
||||
// EnableDHCP enables DHCP on the device if enabled=true and performs a DHCP request.
|
||||
@@ -116,6 +123,18 @@ type InterfaceConfig struct {
|
||||
MTU uint16
|
||||
}
|
||||
|
||||
// RunnerBuffers returns 32-bit aligned contiguous buffers for using with [RunnerConfig].
|
||||
func (iface *Interface[C]) RunnerBuffers(n int) [][]byte {
|
||||
frmlen32 := (iface.frameSize + 3) / 4
|
||||
rawBuf32 := make([]uint32, n*frmlen32) // ensure memory aligned
|
||||
bufs := make([][]byte, n)
|
||||
for i := range n {
|
||||
buf32 := rawBuf32[i*frmlen32 : (i+1)*frmlen32]
|
||||
bufs[i] = unsafe.Slice((*byte)(unsafe.Pointer(&buf32[0])), iface.frameSize)
|
||||
}
|
||||
return bufs
|
||||
}
|
||||
|
||||
// Init initializes the interface from scratch with a netlink and device. If Init fails all methods on Interface are unsafe to call (panic).
|
||||
func (iface *Interface[C]) Init(netlink Netlink[C], dev DevEthernet, cfg InterfaceConfig) (err error) {
|
||||
if netlink == nil || dev == nil {
|
||||
|
||||
+415
-80
@@ -3,91 +3,328 @@ package netdev
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
// Runner orchestrates an Interface and a Stack asynchronously.
|
||||
type Runner[C any] struct {
|
||||
running atomic.Uint32
|
||||
// buflen stores the length of data inside buf. It is used as a buffer acquisition synchronizing primitive.
|
||||
buflen atomic.Uint32
|
||||
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
|
||||
pktlost atomic.Uint64
|
||||
tx, rx atomic.Uint64
|
||||
// buf stores actual data.
|
||||
buf []byte
|
||||
// bufsaux is used as ana argument to stack processing so that no allocations are performed
|
||||
bufsaux [1][]byte
|
||||
sizesaux [1]int
|
||||
handlerTriggered bool
|
||||
deviceIsPollOnly bool
|
||||
var (
|
||||
errRunnerAcquired = errors.New("runner currently running")
|
||||
errNotDriven = errors.New("runner needs to be poll/async driven")
|
||||
errWakeNeedsAsync = errors.New("runner wake needs to be async driven")
|
||||
errNoBackoffNeedsWake = errors.New("wake mechanic not set for omitting backoff")
|
||||
errAsyncHandlingWithRun = errors.New("incompatible use of EnableAsyncHandling with Run- use with RunOnce")
|
||||
errEgressInvalidWrite = errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
|
||||
)
|
||||
|
||||
// RunnerFlags selects how a [Runner] drives its [Interface]. See [RunnerConfig.Flags].
|
||||
type RunnerFlags uint32
|
||||
|
||||
const (
|
||||
// Signals Interface needs to be driven via [DevEthernet.EthPoll].
|
||||
// [RunnerAsync] can be set too to signal packets received via callback instead of written to EthPoll buffer.
|
||||
RunnerInterfacePoll RunnerFlags = 1 << iota
|
||||
// Interface data channel driven exclusively by callback passed to [DevEthernet.SetEthRecvHandler].
|
||||
// If set [DevEthernet.EthPoll] must not write data to argument buffer.
|
||||
RunnerInterfaceAsync
|
||||
// Runner backs off but also wakes up on receiving data asynchronously.
|
||||
// Needs [RunnerInterfaceAsync] to be set to be effective.
|
||||
RunnerAsyncWakeOnRx
|
||||
// Runner will not use the backoff to queue timer wakeups. When setting this option
|
||||
// the user is responsible for waking up the stack so that it can transmit even when not receiving data.
|
||||
// Needs [RunnerAsyncWakeOnRx] to be set to be effective.
|
||||
RunnerNoBackoff
|
||||
)
|
||||
|
||||
// HasAll reports whether every bit in query is set.
|
||||
func (rf RunnerFlags) HasAll(query RunnerFlags) bool { return rf&query == query }
|
||||
|
||||
// HasAny reports whether at least one bit in query is set.
|
||||
func (rf RunnerFlags) HasAny(query RunnerFlags) bool { return rf&query != 0 }
|
||||
|
||||
// Validate reports whether the flag combination is a usable [Runner] configuration.
|
||||
func (rf RunnerFlags) Validate() error {
|
||||
driven := rf.HasAny(RunnerInterfaceAsync | RunnerInterfacePoll)
|
||||
wake := rf.HasAny(RunnerAsyncWakeOnRx)
|
||||
if !driven {
|
||||
return errNotDriven
|
||||
} else if wake && !rf.HasAll(RunnerInterfaceAsync) {
|
||||
return errWakeNeedsAsync
|
||||
} else if rf.HasAny(RunnerNoBackoff) && !wake {
|
||||
return errNoBackoffNeedsWake
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, backoff lneto.BackoffStrategy) error {
|
||||
if stack == nil || backoff == nil {
|
||||
return errors.New("nil arguments to Run")
|
||||
// Runner orchestrates an Interface and a Stack asynchronously.
|
||||
// The complexity of Runner stems from it needing to handle three types of devices, see [RunnerConfig.Flags] documentation.
|
||||
type Runner[C any] struct {
|
||||
running atomic.Uint32
|
||||
bufs bufferSelect
|
||||
backoff lneto.BackoffStrategy
|
||||
reconnect *C
|
||||
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
|
||||
// Does not include packets dropped by the stack ([lneto.ErrPacketDrop]); the stack counts those itself.
|
||||
pktlost atomic.Uint64
|
||||
// rx includes ALL data received, even dropped data. xnet.StackAsync keeps track of actual processed data.
|
||||
rx atomic.Uint64
|
||||
// rxStackErrs/rxPollErrs/txStackErrs/txSendErrs count receive/transmit path
|
||||
// errors which are intentionally not propagated out of the run loop nor
|
||||
// printed in the datapath. See [RunnerStatistics] for per-counter semantics.
|
||||
rxStackErrs, rxPollErrs atomic.Uint64
|
||||
txStackErrs, txSendErrs atomic.Uint64
|
||||
// bufsaux is used as an argument to stack processing so that no allocations are performed
|
||||
bufsaux [1][]byte
|
||||
sizesaux [1]int
|
||||
// flags is atomic since [Runner.Wake] reads it from arbitrary goroutines
|
||||
// concurrently with Configure. Configure stores it last so a visible
|
||||
// RunnerAsyncWakeOnRx bit guarantees a non-nil wake channel.
|
||||
flags atomic.Uint32
|
||||
wake chan struct{}
|
||||
waketimer *time.Timer
|
||||
asyncH *Interface[C]
|
||||
}
|
||||
|
||||
// RunnerConfig configures a [Runner]. Used in [Runner.Configure].
|
||||
type RunnerConfig[C any] struct {
|
||||
// Buffers are the Rx/Tx packet buffers. At least one is required. Use
|
||||
// [Interface.RunnerBuffers] to get correctly sized, aligned buffers.
|
||||
Buffers [][]byte
|
||||
// ReconnectParams is stored for use during link reconnection. Optional.
|
||||
ReconnectParams *C
|
||||
// Backoff is the idle wait strategy between loop iterations. Required.
|
||||
Backoff lneto.BackoffStrategy
|
||||
// Flags must be set to be Async, Poll driven, or both.
|
||||
// This selects the kind of device operation:
|
||||
// - [RunnerInterfacePoll]: Entirely poll driven Rx [DevEthernet] i.e: ESP32 family.
|
||||
// - [RunnerInterfaceAsync]: Entirely async driven Rx (IRQ) [DevEthernet] i.e: LAN8720.
|
||||
// - [RunnerInterfacePoll]|[RunnerInterfaceAsync]: Hybrid Rx [DevEthernet] that are poll driven but can receive data outside EthPoll method. i.e: CYW43439.
|
||||
Flags RunnerFlags
|
||||
}
|
||||
|
||||
// RunnerStatistics keeps track of send/receive statistics.
|
||||
// It is an incomplete view that should be combined with the likes of xnet.StackAsync.ReadStatistics.
|
||||
// It also does not have a view into packets dropped by the [DevEthernet] because of insufficient/slow polling.
|
||||
type RunnerStatistics struct {
|
||||
// Rx total bytes received from interface including packets dropped.
|
||||
Rx uint64
|
||||
// RxPacketsDropped incremented by 1 each time there is not enough resources to process an incoming packet.
|
||||
// This does NOT include packets dropped by the Stack with [lneto.ErrPacketDrop].
|
||||
RxPacketsDropped uint64
|
||||
// RxPollErrs increments by 1 each time polling the [Interface] returns an error.
|
||||
// Is irrelevant for callback driven [DevEthernet].
|
||||
RxPollErrs uint64
|
||||
// RxStackErrs increments by 1 each time stack returns a non [lneto.ErrPacketDrop] error.
|
||||
RxStackErrs uint64
|
||||
// TxStackErrs increments by 1 each time stack returns an error generating egress packets.
|
||||
TxStackErrs uint64
|
||||
// TxSendErrs increments by 1 each time sending a frame over [Interface] returns error.
|
||||
TxSendErrs uint64
|
||||
// BufAcquireFail increments by 1 each time a buffer is unable to be acquired for Rx/Tx.
|
||||
BufAcquireFail uint64
|
||||
}
|
||||
|
||||
// ReadStatistics reads statistics of Runner into [RunnerStatistics].
|
||||
func (r *Runner[C]) ReadStatistics(stats *RunnerStatistics) {
|
||||
*stats = RunnerStatistics{
|
||||
Rx: r.rx.Load(),
|
||||
RxPacketsDropped: r.pktlost.Load(),
|
||||
RxPollErrs: r.rxPollErrs.Load(),
|
||||
RxStackErrs: r.rxStackErrs.Load(),
|
||||
TxStackErrs: r.txStackErrs.Load(),
|
||||
TxSendErrs: r.txSendErrs.Load(),
|
||||
BufAcquireFail: uint64(r.bufs.missedAcquire.Load()),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner[C]) getFlags() RunnerFlags { return RunnerFlags(r.flags.Load()) }
|
||||
|
||||
// Wake unblocks a [Runner] sleeping in [RunnerAsyncWakeOnRx] mode so it services the
|
||||
// stack immediately instead of waiting out the backoff. Signals coalesce and never block.
|
||||
// Safe to call from any goroutine.
|
||||
// Returns an error if the Runner is not configured for wake mode.
|
||||
func (r *Runner[C]) Wake() error {
|
||||
if !r.getFlags().HasAny(RunnerAsyncWakeOnRx) {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
select {
|
||||
case r.wake <- struct{}{}: // signal waiting runner
|
||||
default: // already pending — coalesce, never block
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure validates cfg and applies it to the Runner. Call before [Runner.Run].
|
||||
// Returns an error on invalid flags, missing buffers/backoff, or while the Runner is running.
|
||||
func (r *Runner[C]) Configure(cfg RunnerConfig[C]) error {
|
||||
if err := cfg.Flags.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Buffers) < 1 {
|
||||
return lneto.ErrInvalidConfig
|
||||
} else if cfg.Backoff == nil {
|
||||
return lneto.ErrMissingHALConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return errors.New("runner currently running.")
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer func() {
|
||||
iface.dev.SetEthRecvHandler(nil)
|
||||
r.release()
|
||||
}()
|
||||
r.rx.Store(0)
|
||||
r.tx.Store(0)
|
||||
r.buflen.Store(0)
|
||||
r.pktlost.Store(0)
|
||||
r.handlerTriggered = false
|
||||
r.deviceIsPollOnly = false
|
||||
defer r.release()
|
||||
r.flags.Store(0) // Disable Wake during reconfiguration.
|
||||
r.teardownAsync()
|
||||
r.bufs.reset(cfg.Buffers)
|
||||
r.backoff = cfg.Backoff
|
||||
r.reconnect = cfg.ReconnectParams
|
||||
if cfg.Flags.HasAny(RunnerAsyncWakeOnRx) && r.wake == nil {
|
||||
r.wake = make(chan struct{}, 1)
|
||||
r.waketimer = time.NewTimer(24 * time.Hour)
|
||||
}
|
||||
r.flags.Store(uint32(cfg.Flags)) // Publish flags last; see field comment.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnce performs a single Rx-then-Tx service cycle and returns the bytes received
|
||||
// and transmitted. It does no backoff, wake wait, or state reset: the caller controls
|
||||
// pacing and must [Runner.Configure] (with a non-nil stack) before the first call.
|
||||
// Returns an error if a [Runner.Run] or another RunOnce is already in progress.
|
||||
//
|
||||
// Unlike [Runner.Run], RunOnce does not install the async receive handler. For a
|
||||
// poll-driven interface ([RunnerInterfacePoll]) it works as-is; for an async interface
|
||||
// ([RunnerInterfaceAsync]) call [Runner.EnableAsyncHandling] once beforehand so delivered
|
||||
// frames are captured.
|
||||
func (r *Runner[C]) RunOnce(iface *Interface[C], stack Stack) (nrx, ntx int, err error) {
|
||||
if stack == nil {
|
||||
return 0, 0, lneto.ErrInvalidConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return 0, 0, errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
flags := r.getFlags()
|
||||
async := flags.HasAny(RunnerInterfaceAsync)
|
||||
poll := flags.HasAny(RunnerInterfacePoll)
|
||||
bufsize := iface.bufsize()
|
||||
if cap(r.buf) < bufsize {
|
||||
r.buf = make([]byte, bufsize)
|
||||
nrx, ntx, err = r.service(iface, stack, bufsize, poll, async)
|
||||
return nrx, ntx, err
|
||||
}
|
||||
|
||||
// EnableAsyncHandling installs the Runner's async receive handler on iface so that
|
||||
// frames delivered via [DevEthernet.SetEthRecvHandler] are captured into the buffer
|
||||
// pool. Use it to drive an async interface with [Runner.RunOnce], which (unlike
|
||||
// [Runner.Run]) does not install the handler itself. [Runner.Run] manages the handler
|
||||
// on its own and does not need this.
|
||||
//
|
||||
// Returns [lneto.ErrUnsupported] if the Runner is not configured async
|
||||
// ([RunnerInterfaceAsync]), or an error if a Run/RunOnce is in progress.
|
||||
func (r *Runner[C]) EnableAsyncHandling(iface *Interface[C]) error {
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
r.buf = r.buf[:bufsize]
|
||||
defer r.release()
|
||||
if !r.getFlags().HasAny(RunnerInterfaceAsync) {
|
||||
return lneto.ErrUnsupported
|
||||
}
|
||||
r.asyncH = iface
|
||||
iface.dev.SetEthRecvHandler(r.recvEthHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableAsyncHandling removes the receive handler installed by [Runner.EnableAsyncHandling],
|
||||
// stopping async frame delivery into the buffer pool. Call before reconfiguring or tearing
|
||||
// down the Runner. No-op if async handling was not enabled.
|
||||
func (r *Runner[C]) DisableAsyncHandling() error {
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
r.teardownAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner[C]) teardownAsync() {
|
||||
if r.asyncH != nil {
|
||||
r.asyncH.dev.SetEthRecvHandler(nil)
|
||||
r.asyncH = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives iface and stack until ctx is cancelled, doing one Rx then Tx per iteration
|
||||
// and backing off when idle. Only one Run (and not concurrent with Configure) may execute
|
||||
// at a time. Returns ctx.Err().
|
||||
func (r *Runner[C]) Run(ctx context.Context, iface *Interface[C], stack Stack) error {
|
||||
if stack == nil {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
if !r.acquire() {
|
||||
return errRunnerAcquired
|
||||
}
|
||||
defer r.release()
|
||||
if r.asyncH != nil {
|
||||
return errAsyncHandlingWithRun
|
||||
}
|
||||
r.bufs.releaseAll()
|
||||
r.rx.Store(0)
|
||||
r.pktlost.Store(0)
|
||||
r.rxStackErrs.Store(0)
|
||||
r.rxPollErrs.Store(0)
|
||||
r.txStackErrs.Store(0)
|
||||
r.txSendErrs.Store(0)
|
||||
bufsize := iface.bufsize()
|
||||
flags := r.getFlags()
|
||||
async := flags.HasAny(RunnerInterfaceAsync)
|
||||
poll := flags.HasAny(RunnerInterfacePoll)
|
||||
wake := flags.HasAny(RunnerAsyncWakeOnRx)
|
||||
backoffEnabled := !flags.HasAny(RunnerNoBackoff)
|
||||
if wake {
|
||||
r.waketimer.Stop()
|
||||
}
|
||||
backoff := r.backoff
|
||||
if async {
|
||||
iface.dev.SetEthRecvHandler(r.recvEthHandler)
|
||||
// Only tear down the handler Run itself installed. In poll-only mode
|
||||
// any handler on the device is not Run's to clear.
|
||||
defer iface.dev.SetEthRecvHandler(nil)
|
||||
}
|
||||
|
||||
// backoffs stores number of consecutive times no data was sent/received.
|
||||
var backoffs uint
|
||||
for ctx.Err() == nil {
|
||||
n1, _ := r.processRx(stack, 0)
|
||||
eoff, efrm, err := iface.dev.EthPoll(r.buf)
|
||||
n2, _ := r.processRx(stack, 0)
|
||||
if efrm > 0 && n2 == 0 {
|
||||
r.deviceIsPollOnly = true
|
||||
r.buflen.Store(uint32(eoff + efrm))
|
||||
r.processRx(stack, eoff)
|
||||
} else if efrm > 0 && n2 > 0 {
|
||||
return errors.New("device both returns nonzero poll read and calls, choose one")
|
||||
} else if err != nil {
|
||||
println("err EthPoll:", err.Error())
|
||||
}
|
||||
|
||||
// Now do Tx, but first acquire buffer.
|
||||
if !r.buflen.CompareAndSwap(0, 1) {
|
||||
continue // Oh no, async data received, go back to Rx processing.
|
||||
}
|
||||
r.bufsaux = [1][]byte{r.buf}
|
||||
|
||||
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
|
||||
n := r.sizesaux[0]
|
||||
nrx, ntx, err := r.service(iface, stack, bufsize, poll, async)
|
||||
if err != nil {
|
||||
println("err EgressPackets:", err.Error())
|
||||
} else if n > 0 {
|
||||
if n+iface.frameOff > len(r.buf) {
|
||||
return errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
|
||||
}
|
||||
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:n+iface.frameOff])
|
||||
r.tx.Add(uint64(n + iface.frameOff))
|
||||
if err != nil {
|
||||
println("err SendOffsetEthFrame:", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
r.buflen.Store(0) // Release buffer.
|
||||
if n1 > 0 || n2 > 0 || efrm > 0 || n > 0 {
|
||||
if nrx > 0 || ntx > 0 {
|
||||
backoffs = 0
|
||||
} else if wake {
|
||||
if backoffEnabled {
|
||||
d := backoff(backoffs)
|
||||
backoffs++
|
||||
switch d {
|
||||
case lneto.BackoffFlagGosched:
|
||||
runtime.Gosched()
|
||||
fallthrough
|
||||
case lneto.BackoffFlagNop:
|
||||
continue
|
||||
default:
|
||||
d = max(d, 100*time.Microsecond)
|
||||
}
|
||||
// Claude say:
|
||||
// Reset without draining waketimer.C assumes Go 1.23+ timer
|
||||
// semantics (stale expiries do not linger in the channel). On
|
||||
// runtimes with older semantics (e.g. TinyGo) worst case is a
|
||||
// single spurious early wakeup, which is benign here.
|
||||
r.waketimer.Reset(d)
|
||||
}
|
||||
select {
|
||||
case <-r.wake:
|
||||
backoffs = 0 // woke early on data.
|
||||
case <-ctx.Done():
|
||||
case <-r.waketimer.C:
|
||||
}
|
||||
if backoffEnabled {
|
||||
r.waketimer.Stop()
|
||||
}
|
||||
} else {
|
||||
backoff.Do(backoffs)
|
||||
backoffs++
|
||||
@@ -96,20 +333,68 @@ func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, ba
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (r *Runner[C]) service(iface *Interface[C], stack Stack, bufsize int, poll, async bool) (nrx, ntx int, err error) {
|
||||
nrx, err = r.doRx(iface, stack, poll, async)
|
||||
|
||||
// Now do Tx, but first acquire buffer.
|
||||
txbuf := r.bufs.acquire(bufsize)
|
||||
if txbuf == nil {
|
||||
// We got blocked by Rx. Try draining rx.
|
||||
for range len(r.bufs.bufs) {
|
||||
n, err := r.doRx(iface, stack, poll, async)
|
||||
if err != nil {
|
||||
|
||||
break
|
||||
}
|
||||
nrx += n
|
||||
}
|
||||
txbuf = r.bufs.acquire(bufsize)
|
||||
if txbuf == nil {
|
||||
// Buffers still held by in-flight Rx. Skip Tx this cycle rather
|
||||
// than steal a slot the receive handler may be writing into.
|
||||
return nrx, 0, nil
|
||||
}
|
||||
}
|
||||
r.bufsaux = [1][]byte{txbuf}
|
||||
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
|
||||
ntx = r.sizesaux[0]
|
||||
if err != nil {
|
||||
r.txStackErrs.Add(1)
|
||||
} else if ntx > 0 {
|
||||
if ntx+iface.frameOff > len(txbuf) {
|
||||
r.bufs.release(txbuf)
|
||||
return nrx, 0, errEgressInvalidWrite
|
||||
}
|
||||
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:ntx+iface.frameOff])
|
||||
if err != nil {
|
||||
r.txSendErrs.Add(1)
|
||||
}
|
||||
}
|
||||
r.bufs.release(txbuf) // Release buffer.
|
||||
return nrx, ntx, nil
|
||||
}
|
||||
|
||||
// PrintDebug
|
||||
//
|
||||
// Deprecated: Might be given other shape in future, but this is not how we do debugging. use freely meanwhile.
|
||||
func (r *Runner[C]) PrintDebug() {
|
||||
print("RUNNER: tx|rx:", r.tx.Load(), "|", r.rx.Load(),
|
||||
" devpollonly:", r.deviceIsPollOnly, " pktlost:", r.pktlost.Load(),
|
||||
" handles:", r.handlerTriggered, " bufsize:", len(r.buf),
|
||||
flags := r.getFlags()
|
||||
print("RUNNER: rx:", r.rx.Load(),
|
||||
" pktlost:", r.pktlost.Load(),
|
||||
" rxErrs:", r.rxStackErrs.Load(),
|
||||
" txErrs:", r.txSendErrs.Load(),
|
||||
" devPoll:", flags.HasAny(RunnerInterfacePoll),
|
||||
" devAsync:", flags.HasAny(RunnerInterfaceAsync),
|
||||
" devWakeRx:", flags.HasAny(RunnerAsyncWakeOnRx),
|
||||
"\n")
|
||||
}
|
||||
|
||||
// acquire takes the single-use lock, returning false if already held.
|
||||
func (r *Runner[C]) acquire() bool {
|
||||
return r.running.CompareAndSwap(0, 1)
|
||||
}
|
||||
|
||||
// release frees the lock taken by acquire. Panics if not held.
|
||||
func (r *Runner[C]) release() {
|
||||
if r.running.Load()&1 == 0 {
|
||||
panic("release of unacquired resource")
|
||||
@@ -119,23 +404,73 @@ func (r *Runner[C]) release() {
|
||||
|
||||
// recvEthHandler is called asynchronously. Should be as fast as possible. Do not block inside.
|
||||
func (r *Runner[C]) recvEthHandler(incomingEthernet []byte) {
|
||||
if !r.buflen.CompareAndSwap(0, uint32(len(incomingEthernet))) {
|
||||
// Failed to acquire buffer, packet dropped.
|
||||
r.rx.Add(uint64(len(incomingEthernet))) // rx includes dropped data. xnet.StackAsync keeps track of actual received statistics.
|
||||
if !r.bufs.goroPutRx(incomingEthernet) {
|
||||
// No free buffer or frame oversize, packet dropped.
|
||||
r.pktlost.Add(1)
|
||||
return
|
||||
}
|
||||
copy(r.buf, incomingEthernet)
|
||||
r.Wake()
|
||||
}
|
||||
|
||||
// processRx is called after a packet is received asynchronously and compied to buffer via recvEthHandler
|
||||
func (r *Runner[C]) processRx(stack Stack, ethFrameOff int) (int, error) {
|
||||
r.handlerTriggered = true
|
||||
n := r.buflen.Load()
|
||||
if n == 0 {
|
||||
// doRx services one Rx cycle. In poll mode it reads a frame from the device into a buffer
|
||||
// and ingresses it. In async mode it drains frames delivered by recvEthHandler, pumping a
|
||||
// poll-driven device with EthPoll(nil) when buffers are free. Device errors are counted
|
||||
// in rxPollErrs; the returned error is the first stack ingress error encountered.
|
||||
func (r *Runner[C]) doRx(iface *Interface[C], stack Stack, poll, async bool) (n int, gerr error) {
|
||||
if !async { // poll guaranteed to be set as per RunnerFlags.Validate.
|
||||
// Poll-only doRx.
|
||||
buf := r.bufs.acquire(iface.frameSize)
|
||||
if buf == nil {
|
||||
return 0, nil
|
||||
}
|
||||
eoff, efrm, err := iface.dev.EthPoll(buf)
|
||||
if err != nil {
|
||||
r.rxPollErrs.Add(1)
|
||||
}
|
||||
if efrm > 0 {
|
||||
r.bufsaux = [1][]byte{buf[:eoff+efrm]}
|
||||
gerr = stack.IngressPackets(r.bufsaux[:], eoff)
|
||||
if gerr != nil && gerr != lneto.ErrPacketDrop {
|
||||
r.rxStackErrs.Add(1)
|
||||
}
|
||||
}
|
||||
r.bufs.release(buf)
|
||||
r.rx.Add(uint64(efrm))
|
||||
return efrm, gerr
|
||||
}
|
||||
|
||||
// Async branch.
|
||||
n, gerr = r.processAsyncRx(stack)
|
||||
if poll && r.bufs.numFree() > 0 {
|
||||
// Manual polling required by device.
|
||||
// Data not transmitted via this channel as per RunnerFlags documentation.
|
||||
_, _, err := iface.dev.EthPoll(nil)
|
||||
if err != nil {
|
||||
r.rxPollErrs.Add(1)
|
||||
}
|
||||
} else {
|
||||
return n, gerr
|
||||
}
|
||||
n2, err := r.processAsyncRx(stack)
|
||||
if gerr == nil {
|
||||
gerr = err
|
||||
}
|
||||
return n + n2, gerr
|
||||
}
|
||||
|
||||
// processAsyncRx ingresses one frame previously copied to a buffer by recvEthHandler.
|
||||
// Returns the frame length, or 0 if none is pending.
|
||||
func (r *Runner[C]) processAsyncRx(stack Stack) (int, error) {
|
||||
buf := r.bufs.getRx()
|
||||
if buf == nil {
|
||||
return 0, nil
|
||||
}
|
||||
r.rx.Add(uint64(n))
|
||||
defer r.buflen.Store(0)
|
||||
r.bufsaux = [1][]byte{r.buf[:n]}
|
||||
return int(n), stack.IngressPackets(r.bufsaux[:], ethFrameOff)
|
||||
r.bufsaux = [1][]byte{buf}
|
||||
err := stack.IngressPackets(r.bufsaux[:], 0)
|
||||
if err != nil && err != lneto.ErrPacketDrop {
|
||||
r.rxStackErrs.Add(1)
|
||||
}
|
||||
r.bufs.release(buf)
|
||||
return len(buf), err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package netdev_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/x/netdev"
|
||||
)
|
||||
|
||||
// mockDev is a DevEthernet test double. The mutex makes SetEthRecvHandler
|
||||
// honor the quiescence guarantee with respect to deliver and EthPoll.
|
||||
type mockDev struct {
|
||||
mu sync.Mutex
|
||||
handler func([]byte)
|
||||
sent [][]byte
|
||||
rxq [][]byte // frames pending delivery via EthPoll (poll mode or pump).
|
||||
pumped int
|
||||
frameSize int
|
||||
frameOff int
|
||||
pollErr error
|
||||
sendErr error
|
||||
}
|
||||
|
||||
func (d *mockDev) HardwareAddr6() ([6]byte, error) {
|
||||
return [6]byte{0xde, 0xad, 0xbe, 0xef, 0, 1}, nil
|
||||
}
|
||||
|
||||
func (d *mockDev) SendOffsetEthFrame(f []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.sendErr != nil {
|
||||
return d.sendErr
|
||||
}
|
||||
d.sent = append(d.sent, append([]byte(nil), f...))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *mockDev) SetEthRecvHandler(h func(rxEthFrame []byte)) {
|
||||
d.mu.Lock()
|
||||
d.handler = h
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// deliver invokes the installed receive handler as a driver goroutine would.
|
||||
// Returns false if no handler is installed.
|
||||
func (d *mockDev) deliver(frame []byte) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.handler == nil {
|
||||
return false
|
||||
}
|
||||
d.handler(frame)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *mockDev) handlerInstalled() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.handler != nil
|
||||
}
|
||||
|
||||
func (d *mockDev) numSent() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return len(d.sent)
|
||||
}
|
||||
|
||||
func (d *mockDev) queueRx(frame []byte) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.rxq = append(d.rxq, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
func (d *mockDev) EthPoll(buf []byte) (int, int, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.handler != nil {
|
||||
// Pump mode: frames go through the handler, buf must not be written.
|
||||
if buf != nil {
|
||||
return 0, 0, errors.New("EthPoll got non-nil buf with handler set")
|
||||
}
|
||||
d.pumped++
|
||||
for _, f := range d.rxq {
|
||||
d.handler(f)
|
||||
}
|
||||
d.rxq = nil
|
||||
return 0, 0, d.pollErr
|
||||
}
|
||||
if len(d.rxq) == 0 {
|
||||
return 0, 0, d.pollErr
|
||||
}
|
||||
f := d.rxq[0]
|
||||
d.rxq = d.rxq[1:]
|
||||
n := copy(buf[d.frameOff:], f)
|
||||
return d.frameOff, n, nil
|
||||
}
|
||||
|
||||
func (d *mockDev) MaxFrameSizeAndOffset() (int, int) { return d.frameSize, d.frameOff }
|
||||
|
||||
type mockNetlink struct{}
|
||||
|
||||
func (mockNetlink) LinkConnect(_ struct{}) error { return nil }
|
||||
func (mockNetlink) LinkDisconnect() {}
|
||||
func (mockNetlink) LinkNotify(_ netdev.NotifyCallback[struct{}]) {}
|
||||
|
||||
// mockStack records ingressed frames and emits queued egress frames.
|
||||
type mockStack struct {
|
||||
mu sync.Mutex
|
||||
ingress [][]byte
|
||||
egressq [][]byte
|
||||
ingressErr error
|
||||
egressErr error
|
||||
}
|
||||
|
||||
func (s *mockStack) EnableICMP(bool) error { return nil }
|
||||
|
||||
func (s *mockStack) EnableDHCP(context.Context, bool, netip.Addr) (netip.Addr, netip.Addr, int, error) {
|
||||
return netip.Addr{}, netip.Addr{}, 0, nil
|
||||
}
|
||||
|
||||
func (s *mockStack) Socket(context.Context, string, int, int, netip.AddrPort, netip.AddrPort) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *mockStack) EgressPackets(bufs [][]byte, sizes []int, offset int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.egressErr != nil {
|
||||
return s.egressErr
|
||||
}
|
||||
for i := range bufs {
|
||||
sizes[i] = 0
|
||||
if len(s.egressq) == 0 {
|
||||
continue
|
||||
}
|
||||
sizes[i] = copy(bufs[i][offset:], s.egressq[0])
|
||||
s.egressq = s.egressq[1:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStack) IngressPackets(bufs [][]byte, offset int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ingressErr != nil {
|
||||
return s.ingressErr
|
||||
}
|
||||
for _, b := range bufs {
|
||||
s.ingress = append(s.ingress, append([]byte(nil), b[offset:]...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStack) queueEgress(frame []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.egressq = append(s.egressq, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
func (s *mockStack) numIngress() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.ingress)
|
||||
}
|
||||
|
||||
func newIface(t *testing.T, dev *mockDev) *netdev.Interface[struct{}] {
|
||||
t.Helper()
|
||||
if dev.frameSize == 0 {
|
||||
dev.frameSize = 1514 + dev.frameOff
|
||||
}
|
||||
var iface netdev.Interface[struct{}]
|
||||
err := iface.Init(mockNetlink{}, dev, netdev.InterfaceConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &iface
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, iface *netdev.Interface[struct{}], nbufs int, flags netdev.RunnerFlags, backoff lneto.BackoffStrategy) *netdev.Runner[struct{}] {
|
||||
t.Helper()
|
||||
if backoff == nil {
|
||||
backoff = func(uint) time.Duration { return time.Millisecond }
|
||||
}
|
||||
var r netdev.Runner[struct{}]
|
||||
err := r.Configure(netdev.RunnerConfig[struct{}]{
|
||||
Buffers: iface.RunnerBuffers(nbufs),
|
||||
Backoff: backoff,
|
||||
Flags: flags,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// testFrame returns a frame whose payload encodes and repeats seq for
|
||||
// integrity checking with checkFrame.
|
||||
func testFrame(seq uint32, size int) []byte {
|
||||
f := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(f, seq)
|
||||
for i := 4; i < size; i++ {
|
||||
f[i] = byte(seq)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func checkFrame(t *testing.T, f []byte, size int) uint32 {
|
||||
t.Helper()
|
||||
if len(f) != size {
|
||||
t.Fatalf("frame length %d, want %d", len(f), size)
|
||||
}
|
||||
seq := binary.LittleEndian.Uint32(f)
|
||||
for i := 4; i < len(f); i++ {
|
||||
if f[i] != byte(seq) {
|
||||
t.Fatalf("frame seq %d corrupt at byte %d: got %#x want %#x", seq, i, f[i], byte(seq))
|
||||
}
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
func TestRunnerFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
flags netdev.RunnerFlags
|
||||
ok bool
|
||||
}{
|
||||
{flags: 0, ok: false},
|
||||
{flags: netdev.RunnerInterfacePoll, ok: true},
|
||||
{flags: netdev.RunnerInterfaceAsync, ok: true},
|
||||
{flags: netdev.RunnerInterfacePoll | netdev.RunnerInterfaceAsync, ok: true},
|
||||
{flags: netdev.RunnerAsyncWakeOnRx, ok: false},
|
||||
{flags: netdev.RunnerInterfacePoll | netdev.RunnerAsyncWakeOnRx, ok: false},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx, ok: true},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerNoBackoff, ok: false},
|
||||
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx | netdev.RunnerNoBackoff, ok: true},
|
||||
} {
|
||||
err := tc.flags.Validate()
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("flags %#b: got err=%v, want ok=%v", tc.flags, err, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOncePollOnly(t *testing.T) {
|
||||
dev := &mockDev{frameOff: 4}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
|
||||
const fsize = 64
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
stack.queueEgress(testFrame(2, fsize))
|
||||
|
||||
nrx, ntx, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nrx != fsize {
|
||||
t.Errorf("nrx=%d, want %d", nrx, fsize)
|
||||
}
|
||||
if ntx != fsize {
|
||||
t.Errorf("ntx=%d, want %d", ntx, fsize)
|
||||
}
|
||||
if stack.numIngress() != 1 {
|
||||
t.Fatalf("ingress=%d, want 1", stack.numIngress())
|
||||
}
|
||||
if got := checkFrame(t, stack.ingress[0], fsize); got != 1 {
|
||||
t.Errorf("ingress seq=%d, want 1", got)
|
||||
}
|
||||
if dev.numSent() != 1 {
|
||||
t.Fatalf("sent=%d, want 1", dev.numSent())
|
||||
}
|
||||
// Sent frame includes the device frame offset prefix.
|
||||
if got := checkFrame(t, dev.sent[0][dev.frameOff:], fsize); got != 2 {
|
||||
t.Errorf("sent seq=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnceAsyncOrdering checks frames ingress in arrival order, not in
|
||||
// buffer slot order (review: getRx scans slots in index order; reordering
|
||||
// TCP segments triggers dup-ACK/retransmit churn).
|
||||
func TestRunOnceAsyncOrdering(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 4, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const fsize = 64
|
||||
for seq := uint32(1); seq <= 3; seq++ {
|
||||
if !dev.deliver(testFrame(seq, fsize)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
}
|
||||
for range 3 {
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stack.numIngress() != 3 {
|
||||
t.Fatalf("ingress=%d, want 3", stack.numIngress())
|
||||
}
|
||||
for i, f := range stack.ingress {
|
||||
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
|
||||
t.Errorf("ingress[%d] seq=%d, want %d: frames reordered", i, got, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnceAsyncPollPumpSingleBuffer exercises the async+poll pump path with
|
||||
// a single buffer over several cycles. Review (MDr164, buffer.go inline): reset
|
||||
// and release clear lenAcquire but not isRx, so numFree undercounts and the
|
||||
// EthPoll pump is wrongly skipped.
|
||||
func TestRunOnceAsyncPollPumpSingleBuffer(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 1, netdev.RunnerInterfaceAsync|netdev.RunnerInterfacePoll, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const fsize = 64
|
||||
const cycles = 3
|
||||
for seq := uint32(1); seq <= cycles; seq++ {
|
||||
dev.queueRx(testFrame(seq, fsize))
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stack.numIngress() != cycles {
|
||||
t.Fatalf("ingress=%d, want %d: EthPoll pump skipped", stack.numIngress(), cycles)
|
||||
}
|
||||
for i, f := range stack.ingress {
|
||||
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
|
||||
t.Errorf("ingress[%d] seq=%d, want %d", i, got, i+1)
|
||||
}
|
||||
}
|
||||
// Deliver a frame that stays pending in the pool, then reconfigure: reset
|
||||
// must fully clear slot state. A stale isRx mark from the abandoned frame
|
||||
// makes numFree undercount and skip the pump.
|
||||
if !dev.deliver(testFrame(cycles+1, fsize)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
err = r.Configure(netdev.RunnerConfig[struct{}]{
|
||||
Buffers: iface.RunnerBuffers(1),
|
||||
Backoff: func(uint) time.Duration { return time.Millisecond },
|
||||
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev.queueRx(testFrame(cycles+2, fsize))
|
||||
_, _, err = r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The frame in flight at reconfigure time is legitimately dropped; the
|
||||
// queued frame must still arrive through the pump.
|
||||
if stack.numIngress() != cycles+1 {
|
||||
t.Fatalf("ingress=%d, want %d: pump skipped after reconfigure (stale isRx)", stack.numIngress(), cycles+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAfterEnableAsyncHandlingFails checks Run rejects a Runner set up via
|
||||
// EnableAsyncHandling WITHOUT tearing down the installed handler. Review issue
|
||||
// 4: the teardown defer is installed before the asyncH check, silently
|
||||
// uninstalling the handler so subsequent RunOnce drops all async frames.
|
||||
func TestRunAfterEnableAsyncHandlingFails(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err = r.Run(ctx, iface, stack)
|
||||
if err == nil || errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Run after EnableAsyncHandling: got %v, want immediate config error", err)
|
||||
}
|
||||
// The rejected Run must not tear down the handler EnableAsyncHandling installed.
|
||||
if !dev.handlerInstalled() {
|
||||
t.Fatal("Run teardown removed handler it did not install")
|
||||
}
|
||||
if !dev.deliver(testFrame(1, 64)) {
|
||||
t.Fatal("handler not installed")
|
||||
}
|
||||
nrx, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil || nrx != 64 {
|
||||
t.Fatalf("RunOnce after rejected Run: nrx=%d err=%v", nrx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOversizeFrameDropped checks a frame larger than the pool buffers is
|
||||
// dropped instead of panicking. Review: acquireNext slices buf[:len] without a
|
||||
// bounds check — a slice-bounds panic inside the receive path.
|
||||
func TestOversizeFrameDropped(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
|
||||
err := r.EnableAsyncHandling(iface)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("receive handler panicked on oversize frame: %v", recovered)
|
||||
}
|
||||
}()
|
||||
dev.deliver(make([]byte, dev.frameSize+1))
|
||||
}()
|
||||
nrx, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil || nrx != 0 || stack.numIngress() != 0 {
|
||||
t.Fatalf("oversize frame ingressed: nrx=%d ingress=%d err=%v", nrx, stack.numIngress(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBackoffEscalates checks the backoff counter escalates past a
|
||||
// Gosched/Nop opening move. Review issue 5 (MDr164, runner.go inline): continue
|
||||
// skips backoffs++, so a strategy returning Gosched at 0 busy-spins forever.
|
||||
func TestRunBackoffEscalates(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
var mu sync.Mutex
|
||||
var recorded []uint
|
||||
backoff := func(consecutive uint) time.Duration {
|
||||
mu.Lock()
|
||||
recorded = append(recorded, consecutive)
|
||||
mu.Unlock()
|
||||
if consecutive == 0 {
|
||||
return lneto.BackoffFlagGosched
|
||||
}
|
||||
return time.Millisecond
|
||||
}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, backoff)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
err := r.Run(ctx, iface, stack)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("backoff never called")
|
||||
}
|
||||
escalated := false
|
||||
for _, c := range recorded {
|
||||
if c > 0 {
|
||||
escalated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !escalated {
|
||||
t.Fatalf("backoff counter pinned at 0 across %d idle iterations", len(recorded))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAsyncStress hammers the receive handler from a producer goroutine
|
||||
// while Run services the stack under Tx pressure, forcing buffer pool
|
||||
// contention. Run with -race. Review issue 1: forceAcquireTx steals a slot the
|
||||
// receive handler may be concurrently copying into, so partially constructed
|
||||
// frames land on the wire and double release panics the runner. Checks frames
|
||||
// are never corrupted (ingress AND egress) and never reordered.
|
||||
func TestRunAsyncStress(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 3, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, nil)
|
||||
|
||||
const fsize = 64
|
||||
const nframes = 2000
|
||||
const egressSeq = 0xffff
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runDone := make(chan error, 1)
|
||||
go func() { runDone <- r.Run(ctx, iface, stack) }()
|
||||
|
||||
// Egress traffic keeps the Tx path contending with Rx for buffers.
|
||||
for range 200 {
|
||||
stack.queueEgress(testFrame(egressSeq, fsize))
|
||||
}
|
||||
for seq := uint32(1); seq <= nframes; seq++ {
|
||||
for !dev.deliver(testFrame(seq, fsize)) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond) // let the runner drain pending frames.
|
||||
cancel()
|
||||
err := <-runDone
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stack.mu.Lock()
|
||||
if len(stack.ingress) == 0 {
|
||||
t.Fatal("no frames ingressed")
|
||||
}
|
||||
prev := uint32(0)
|
||||
for _, f := range stack.ingress {
|
||||
seq := checkFrame(t, f, fsize)
|
||||
if seq <= prev {
|
||||
t.Fatalf("reordered: seq %d after %d", seq, prev)
|
||||
}
|
||||
prev = seq
|
||||
}
|
||||
numIngress := len(stack.ingress)
|
||||
stack.mu.Unlock()
|
||||
dev.mu.Lock()
|
||||
for _, f := range dev.sent {
|
||||
if seq := checkFrame(t, f, fsize); seq != egressSeq {
|
||||
t.Fatalf("egress frame corrupted: seq=%#x", seq)
|
||||
}
|
||||
}
|
||||
numSent := len(dev.sent)
|
||||
dev.mu.Unlock()
|
||||
t.Logf("ingressed %d/%d frames, sent %d", numIngress, nframes, numSent)
|
||||
}
|
||||
|
||||
// TestRunnerStatisticsCounting checks each RunnerStatistics counter increments
|
||||
// per its documented trigger: device poll errors -> RxPollErrs, stack ingress
|
||||
// errors -> RxStackErrs except ErrPacketDrop which -> RxPacketsDropped, stack
|
||||
// egress errors -> TxStackErrs, and device send errors -> TxSendErrs.
|
||||
func TestRunnerStatisticsCounting(t *testing.T) {
|
||||
const fsize = 64
|
||||
errBoom := errors.New("boom")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(dev *mockDev, stack *mockStack)
|
||||
want netdev.RunnerStatistics
|
||||
}{
|
||||
{
|
||||
// Successful Rx+Tx must not bump any error counter (regression:
|
||||
// missing nil check counted every successful ingress as RxStackErrs).
|
||||
name: "no errors",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
stack.queueEgress(testFrame(2, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize},
|
||||
},
|
||||
{
|
||||
name: "poll error",
|
||||
setup: func(dev *mockDev, stack *mockStack) { dev.pollErr = errBoom },
|
||||
want: netdev.RunnerStatistics{RxPollErrs: 1},
|
||||
},
|
||||
{
|
||||
// ErrPacketDrop is counted by the stack itself, not the Runner:
|
||||
// neither RxPacketsDropped nor RxStackErrs may increment.
|
||||
name: "ingress packet drop",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
stack.ingressErr = lneto.ErrPacketDrop
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize},
|
||||
},
|
||||
{
|
||||
name: "ingress stack error",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
stack.ingressErr = errBoom
|
||||
dev.queueRx(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{Rx: fsize, RxStackErrs: 1},
|
||||
},
|
||||
{
|
||||
name: "egress stack error",
|
||||
setup: func(dev *mockDev, stack *mockStack) { stack.egressErr = errBoom },
|
||||
want: netdev.RunnerStatistics{TxStackErrs: 1},
|
||||
},
|
||||
{
|
||||
name: "send error",
|
||||
setup: func(dev *mockDev, stack *mockStack) {
|
||||
dev.sendErr = errBoom
|
||||
stack.queueEgress(testFrame(1, fsize))
|
||||
},
|
||||
want: netdev.RunnerStatistics{TxSendErrs: 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
tc.setup(dev, stack)
|
||||
_, _, err := r.RunOnce(iface, stack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats netdev.RunnerStatistics
|
||||
r.ReadStatistics(&stats)
|
||||
if stats != tc.want {
|
||||
t.Errorf("stats=%+v, want %+v", stats, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunResetsStatistics checks Run zeroes all counters accumulated by a
|
||||
// previous session, including RxPollErrs.
|
||||
func TestRunResetsStatistics(t *testing.T) {
|
||||
dev := &mockDev{}
|
||||
iface := newIface(t, dev)
|
||||
stack := &mockStack{}
|
||||
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
|
||||
dev.pollErr = errors.New("boom")
|
||||
if _, _, err := r.RunOnce(iface, stack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats netdev.RunnerStatistics
|
||||
r.ReadStatistics(&stats)
|
||||
if stats == (netdev.RunnerStatistics{}) {
|
||||
t.Fatal("expected nonzero statistics before Run")
|
||||
}
|
||||
dev.mu.Lock()
|
||||
dev.pollErr = nil
|
||||
dev.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := r.Run(ctx, iface, stack); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.ReadStatistics(&stats)
|
||||
if stats != (netdev.RunnerStatistics{}) {
|
||||
t.Errorf("Run did not reset statistics: %+v", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package rawsock listens and reads over Linux sockets through syscalls
|
||||
// directly, without the net package. It exists so a server can be measured
|
||||
// without net.TCPConn, its netFD and its poller on the path.
|
||||
//
|
||||
// [Conn] and [Listener] implement [net.Conn] and [net.Listener] the way
|
||||
// TinyGo's net package does: a connection is its file descriptor plus its
|
||||
// addresses, deadlines live on the struct and are handed to the socket, and
|
||||
// nothing sits between a Read and the syscall. A server written against these
|
||||
// interfaces runs unchanged over the net package, over this package and over
|
||||
// TinyGo's netdev.
|
||||
package rawsock
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !tinygo && darwin
|
||||
|
||||
package rawsock
|
||||
|
||||
import "syscall"
|
||||
|
||||
const sysaccept = syscall.SYS_ACCEPT
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !tinygo && linux
|
||||
|
||||
package rawsock
|
||||
|
||||
import "syscall"
|
||||
|
||||
const sysaccept = syscall.SYS_ACCEPT4
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build tinygo || (!linux && !darwin)
|
||||
|
||||
package rawsock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
_ net.Conn = (*Conn)(nil)
|
||||
_ net.Listener = (*Listener)(nil)
|
||||
)
|
||||
|
||||
// Addr is a socket address implementing [net.Addr].
|
||||
type Addr netip.AddrPort
|
||||
|
||||
func (a Addr) Network() string { return "tcp" }
|
||||
func (a Addr) String() string { return netip.AddrPort(a).String() }
|
||||
func (a Addr) AddrPort() netip.AddrPort { return netip.AddrPort(a) }
|
||||
|
||||
// Conn is an accepted connection. Unsupported on this platform.
|
||||
type Conn struct{}
|
||||
|
||||
func (c *Conn) Read(b []byte) (int, error) { return 0, errors.ErrUnsupported }
|
||||
func (c *Conn) Write(b []byte) (int, error) { return 0, errors.ErrUnsupported }
|
||||
func (c *Conn) Close() error { return errors.ErrUnsupported }
|
||||
func (c *Conn) LocalAddr() net.Addr { return Addr{} }
|
||||
func (c *Conn) RemoteAddr() net.Addr { return Addr{} }
|
||||
|
||||
func (c *Conn) SetDeadline(t time.Time) error { return errors.ErrUnsupported }
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error { return errors.ErrUnsupported }
|
||||
func (c *Conn) SetWriteDeadline(t time.Time) error { return errors.ErrUnsupported }
|
||||
func (c *Conn) SetReadTimeout(timeout time.Duration) error { return errors.ErrUnsupported }
|
||||
func (c *Conn) SetWriteTimeout(timeout time.Duration) error { return errors.ErrUnsupported }
|
||||
|
||||
// Listener is a listening socket. Unsupported on this platform.
|
||||
type Listener struct{}
|
||||
|
||||
// Listen is unsupported on this platform.
|
||||
func (l *Listener) Listen(port uint16) error { return errors.ErrUnsupported }
|
||||
|
||||
func (l *Listener) Accept() (net.Conn, error) { return nil, errors.ErrUnsupported }
|
||||
func (l *Listener) AcceptConn(conn *Conn) error { return errors.ErrUnsupported }
|
||||
func (l *Listener) Addr() net.Addr { return Addr{} }
|
||||
func (l *Listener) Port() uint16 { return 0 }
|
||||
func (l *Listener) Close() error { return errors.ErrUnsupported }
|
||||
@@ -0,0 +1,244 @@
|
||||
//go:build !tinygo && (linux || darwin)
|
||||
|
||||
package rawsock
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// The interfaces this package exists to satisfy: an http.Server and a heapless
|
||||
// router must both accept what Listen hands back.
|
||||
var (
|
||||
_ net.Conn = (*Conn)(nil)
|
||||
_ net.Listener = (*Listener)(nil)
|
||||
)
|
||||
|
||||
// Addr is a socket address. It implements [net.Addr] over a
|
||||
// [netip.AddrPort], which costs no allocation to carry around, unlike
|
||||
// [net.TCPAddr] and its IP slice.
|
||||
type Addr netip.AddrPort
|
||||
|
||||
// Network returns "tcp".
|
||||
func (a Addr) Network() string { return "tcp" }
|
||||
|
||||
// String returns the address in host:port form.
|
||||
func (a Addr) String() string { return netip.AddrPort(a).String() }
|
||||
|
||||
// AddrPort returns the address as a [netip.AddrPort].
|
||||
func (a Addr) AddrPort() netip.AddrPort { return netip.AddrPort(a) }
|
||||
|
||||
// Conn is an accepted TCP connection over a raw socket file descriptor. It
|
||||
// implements [net.Conn], so both an [net/http.Server] and a heapless router can
|
||||
// be handed the same connection.
|
||||
//
|
||||
// Deadlines are enforced by the socket itself through SO_RCVTIMEO and
|
||||
// SO_SNDTIMEO: a read that runs out of time fails with a [net.Error] that
|
||||
// reports Timeout, as callers of net.Conn expect.
|
||||
type Conn struct {
|
||||
fd int
|
||||
local, remote Addr
|
||||
readDeadline time.Time
|
||||
writeDeadline time.Time
|
||||
}
|
||||
|
||||
// Read reads bytes from the connection into b.
|
||||
func (c *Conn) Read(b []byte) (int, error) {
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := syscall.Read(c.fd, b)
|
||||
if err != nil {
|
||||
return 0, c.opError("read", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, syscall.ECONNRESET // Peer closed.
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Write writes b to the connection, looping until all bytes are sent.
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
total := 0
|
||||
for total < len(b) {
|
||||
n, err := syscall.Write(c.fd, b[total:])
|
||||
if err != nil {
|
||||
return total, c.opError("write", err)
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying file descriptor.
|
||||
func (c *Conn) Close() error {
|
||||
return syscall.Close(c.fd)
|
||||
}
|
||||
|
||||
// LocalAddr returns the address the connection was accepted on.
|
||||
func (c *Conn) LocalAddr() net.Addr { return c.local }
|
||||
|
||||
// RemoteAddr returns the peer address of the connection.
|
||||
func (c *Conn) RemoteAddr() net.Addr { return c.remote }
|
||||
|
||||
// SetDeadline sets both the read and write deadline. A zero time removes them.
|
||||
func (c *Conn) SetDeadline(t time.Time) error {
|
||||
err := c.SetReadDeadline(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
// SetReadDeadline makes reads after t fail with a timeout error. A zero time
|
||||
// lets reads block indefinitely.
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error {
|
||||
c.readDeadline = t
|
||||
return c.SetReadTimeout(untilDeadline(t))
|
||||
}
|
||||
|
||||
// SetWriteDeadline makes writes after t fail with a timeout error. A zero time
|
||||
// lets writes block indefinitely.
|
||||
func (c *Conn) SetWriteDeadline(t time.Time) error {
|
||||
c.writeDeadline = t
|
||||
return c.SetWriteTimeout(untilDeadline(t))
|
||||
}
|
||||
|
||||
// SetReadTimeout limits how long a single Read waits for data before failing
|
||||
// with a timeout error. Zero blocks indefinitely. This is what stops a peer
|
||||
// that opens a connection and then stalls from holding a server worker: the
|
||||
// connection, not the HTTP handler, owns the idle policy.
|
||||
func (c *Conn) SetReadTimeout(timeout time.Duration) error {
|
||||
return setSockTimeout(c.fd, syscall.SO_RCVTIMEO, timeout)
|
||||
}
|
||||
|
||||
// SetWriteTimeout limits how long a single Write waits for the send buffer to
|
||||
// drain before failing with a timeout error. Zero blocks indefinitely.
|
||||
func (c *Conn) SetWriteTimeout(timeout time.Duration) error {
|
||||
return setSockTimeout(c.fd, syscall.SO_SNDTIMEO, timeout)
|
||||
}
|
||||
|
||||
// opError wraps a socket error the way the net package does, so that callers
|
||||
// which test for a timeout with [net.Error] see one. A deadline that has passed
|
||||
// surfaces from the kernel as EAGAIN, which on a blocking socket only ever
|
||||
// means the timeout fired.
|
||||
func (c *Conn) opError(op string, err error) error {
|
||||
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
|
||||
err = errTimeout{}
|
||||
}
|
||||
return &net.OpError{Op: op, Net: "tcp", Source: c.local, Addr: c.remote, Err: err}
|
||||
}
|
||||
|
||||
// errTimeout is the error a read or write past its deadline fails with.
|
||||
type errTimeout struct{}
|
||||
|
||||
func (errTimeout) Error() string { return "i/o timeout" }
|
||||
func (errTimeout) Timeout() bool { return true }
|
||||
func (errTimeout) Temporary() bool { return true }
|
||||
|
||||
func untilDeadline(t time.Time) time.Duration {
|
||||
if t.IsZero() {
|
||||
return 0 // No deadline: block indefinitely.
|
||||
}
|
||||
remaining := time.Until(t)
|
||||
if remaining <= 0 {
|
||||
return time.Nanosecond // Already past: fail the next call, do not block.
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func setSockTimeout(fd, option int, timeout time.Duration) error {
|
||||
tv := syscall.NsecToTimeval(int64(timeout))
|
||||
return syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, option, &tv)
|
||||
}
|
||||
|
||||
// Listener is a listening TCP socket bound to a local port. It implements
|
||||
// [net.Listener].
|
||||
type Listener struct {
|
||||
fd int
|
||||
local Addr
|
||||
}
|
||||
|
||||
// Listen creates a listening TCP socket bound to port on all interfaces. A
|
||||
// zero port lets the kernel choose one, see [Listener.Addr].
|
||||
func (l *Listener) Listen(port uint16) error {
|
||||
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Allow quick rebind after restart.
|
||||
if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
|
||||
syscall.Close(fd)
|
||||
return err
|
||||
}
|
||||
addr := &syscall.SockaddrInet4{Port: int(port)}
|
||||
if err = syscall.Bind(fd, addr); err != nil {
|
||||
syscall.Close(fd)
|
||||
return err
|
||||
}
|
||||
if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
|
||||
syscall.Close(fd)
|
||||
return err
|
||||
}
|
||||
l.fd = fd
|
||||
l.local = Addr{}
|
||||
bound, err := syscall.Getsockname(fd)
|
||||
if err != nil {
|
||||
syscall.Close(fd)
|
||||
return err
|
||||
}
|
||||
if sa4, ok := bound.(*syscall.SockaddrInet4); ok {
|
||||
l.local = Addr(netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept blocks until an incoming connection arrives and returns it. It
|
||||
// allocates the connection, as [net.Listener] requires. A server that keeps its
|
||||
// own connection storage should call [Listener.AcceptConn] instead.
|
||||
func (l *Listener) Accept() (net.Conn, error) {
|
||||
conn := new(Conn)
|
||||
err := l.AcceptConn(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// AcceptConn blocks until an incoming connection arrives and stores it in conn,
|
||||
// reusing whatever conn already held. Nothing is allocated.
|
||||
//
|
||||
// The syscall is made by hand because [syscall.Accept] allocates the
|
||||
// [syscall.Sockaddr] it returns, one per accepted connection: the kernel is
|
||||
// given address storage this call owns instead, and the address is read out of
|
||||
// it into conn.
|
||||
func (l *Listener) AcceptConn(conn *Conn) error {
|
||||
var rsa syscall.RawSockaddrAny
|
||||
salen := uint32(unsafe.Sizeof(rsa))
|
||||
nfd, _, errno := syscall.Syscall6(sysaccept, uintptr(l.fd),
|
||||
uintptr(unsafe.Pointer(&rsa)), uintptr(unsafe.Pointer(&salen)), 0, 0, 0)
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
*conn = Conn{fd: int(nfd), local: l.local}
|
||||
if rsa.Addr.Family == syscall.AF_INET {
|
||||
sa4 := (*syscall.RawSockaddrInet4)(unsafe.Pointer(&rsa))
|
||||
// Port is in network byte order in the sockaddr the kernel filled.
|
||||
port := uint16(sa4.Port<<8) | uint16(sa4.Port>>8)
|
||||
conn.remote = Addr(netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), port))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns the address the socket is bound to, which carries the port the
|
||||
// kernel picked when the listener was created with port zero.
|
||||
func (l *Listener) Addr() net.Addr { return l.local }
|
||||
|
||||
// Port returns the port the socket is bound to.
|
||||
func (l *Listener) Port() uint16 { return l.local.AddrPort().Port() }
|
||||
|
||||
// Close closes the listening socket.
|
||||
func (l *Listener) Close() error { return syscall.Close(l.fd) }
|
||||
+39
-11
@@ -69,6 +69,8 @@ type StackAsync struct {
|
||||
|
||||
ipv6enabled bool
|
||||
stack6 Stack6
|
||||
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
type StackConfig struct {
|
||||
@@ -105,6 +107,9 @@ type StackConfig struct {
|
||||
AcceptMulticast bool
|
||||
// Accept broadcast IPv4 packets. Needed for managing access points and DHCPv4 servers.
|
||||
AcceptIPv4Broadcast bool
|
||||
// Logger receives the stack's Debug and DebugErr output. A nil Logger silences
|
||||
// them; the heap allocation probe still runs so allocation bisection keeps working.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (cfg *StackConfig) id() uint16 {
|
||||
@@ -165,9 +170,14 @@ func (s *StackAsync) IngressIP(ipFrame []byte) error {
|
||||
func (s *StackAsync) EgressIP(dstIPFrame []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(dstIPFrame) < s.link.MTU() {
|
||||
mtu := s.link.MTU()
|
||||
if len(dstIPFrame) < mtu {
|
||||
return 0, lneto.ErrShortBuffer
|
||||
}
|
||||
// Clip to MTU so downstream layers cannot emit an IP datagram larger than the
|
||||
// link MTU (mirrors StackEthernet.Encapsulate). This also bounds the TCP frame
|
||||
// budget, so the advertised MSS becomes MTU-ipHdr-20 instead of the buffer size.
|
||||
dstIPFrame = dstIPFrame[:mtu]
|
||||
n, err := s.ip4.Encapsulate(dstIPFrame, 0, 0)
|
||||
if s.ipv6enabled && n == 0 {
|
||||
n, err = s.stack6.EgressIPv6(dstIPFrame)
|
||||
@@ -197,6 +207,7 @@ func (s *StackAsync) Reset(cfg StackConfig) (err error) {
|
||||
defer s.mu.Unlock()
|
||||
s.prng = uint32(cfg.RandSeed)
|
||||
s.hostname = cfg.Hostname
|
||||
s.log = cfg.Logger
|
||||
// Treat last character of hostname as number.
|
||||
id := cfg.id()
|
||||
linkNodes := 2 // ARP and IPv4 nodes
|
||||
@@ -545,8 +556,12 @@ func (s *StackAsync) RegisterUDP4(node lneto.StackNode, remoteAddr [4]byte, remo
|
||||
if idx >= cap(s.userUDPs) {
|
||||
return lneto.ErrExhausted
|
||||
}
|
||||
raddr := remoteAddr[:]
|
||||
if remoteAddr == [4]byte{} {
|
||||
raddr = nil
|
||||
}
|
||||
s.userUDPs = s.userUDPs[:idx+1]
|
||||
s.userUDPs[idx].SetStackNode(node, remoteAddr[:], remotePort)
|
||||
s.userUDPs[idx].SetStackNode(node, raddr, remotePort)
|
||||
return s.udps.RegisterMACFiltered(&s.userUDPs[idx], nil)
|
||||
}
|
||||
|
||||
@@ -833,27 +848,40 @@ func addr4(addr [4]byte, ok bool) netip.Addr {
|
||||
return netip.AddrFrom4(addr)
|
||||
}
|
||||
|
||||
// Debug prints debugging information. Very useful for users when coupled with
|
||||
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
|
||||
// Debug prints debugging and heap information.
|
||||
//
|
||||
// go build -tags=debugheaplog ./yourprogram
|
||||
// The heap allocation probe runs unconditionally; only the log line is gated on
|
||||
// the configured Logger's level. Building the slog.Attr list allocates, so the
|
||||
// gate must come first or the allocation happens even when nothing is logged.
|
||||
func (s *StackAsync) Debug(msg string) {
|
||||
internal.LogAttrs(slog.Default(), slog.LevelDebug, "stackasync",
|
||||
internal.LogAllocs(msg)
|
||||
if !internal.LogEnabled(s.log, slog.LevelDebug) {
|
||||
return
|
||||
}
|
||||
internal.LogAttrsAndAllocs(msg, s.log, slog.LevelDebug, "stackasync",
|
||||
slog.String("umsg", msg),
|
||||
slog.Uint64("sent", s.stats.TotalSent),
|
||||
slog.Uint64("recv", s.stats.TotalReceived),
|
||||
)
|
||||
}
|
||||
|
||||
// DebugErr prints debugging and error info. Very useful for users when coupled with
|
||||
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
|
||||
//
|
||||
// go build -tags=debugheaplog ./yourprogram
|
||||
// DebugErr prints debugging and heap information with [slog.LevelError] level. See [StackAsync.Debug] on gating.
|
||||
func (s *StackAsync) DebugErr(msg, err string) {
|
||||
internal.LogAttrs(slog.Default(), slog.LevelError, "stackasync",
|
||||
internal.LogAllocs(msg)
|
||||
if !internal.LogEnabled(s.log, slog.LevelError) {
|
||||
return
|
||||
}
|
||||
internal.LogAttrsAndAllocs(msg, s.log, slog.LevelError, "stackasync",
|
||||
slog.String("umsg", msg),
|
||||
slog.String("err", err),
|
||||
slog.Uint64("sent", s.stats.TotalSent),
|
||||
slog.Uint64("recv", s.stats.TotalReceived),
|
||||
)
|
||||
}
|
||||
|
||||
// LogAllocs is an lneto-tracked allocation logger. If there was an allocation between this call and a previous call
|
||||
// to LogAllocs it will be printed. This is called globally by StackAsync.Debug methods and by all logging calls in lneto
|
||||
// when build tag debugheaplog is set.
|
||||
func LogAllocs(msg string) {
|
||||
internal.LogAllocs(msg)
|
||||
}
|
||||
|
||||
+17
-20
@@ -9,22 +9,19 @@ import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
)
|
||||
|
||||
const (
|
||||
_AF_INET = 0x2
|
||||
_SOCK_STREAM = 0x1
|
||||
_SOCK_DGRAM = 0x2
|
||||
_SOL_SOCKET = 0x1
|
||||
_SO_KEEPALIVE = 0x9
|
||||
_SOL_TCP = 0x6
|
||||
_TCP_KEEPINTVL = 0x5
|
||||
_IPPROTO_TCP = 0x6
|
||||
_IPPROTO_UDP = 0x11
|
||||
AF_INET = 0x2
|
||||
AF_INET6 = 0xa
|
||||
SOCK_STREAM = 0x1
|
||||
SOCK_DGRAM = 0x2
|
||||
|
||||
// Made up, not a real IP protocol number. This is used to create a
|
||||
// TLS socket on the device, assuming the device supports mbed TLS.
|
||||
_IPPROTO_TLS = 0xFE
|
||||
_F_SETFL = 0x4
|
||||
_IPPROTO_TLS = 0xFE // TODO: is this a good idea?
|
||||
)
|
||||
|
||||
// gosocket is the stack abstraction for the baremetal proposal.
|
||||
@@ -92,7 +89,7 @@ func (s *StackBerkeley) SetSockOpt(sockfd int, level int, opt int, value any) er
|
||||
// domain must be AF_INET. stype must be SOCK_STREAM or SOCK_DGRAM.
|
||||
// protocol must be IPPROTO_TCP, IPPROTO_UDP, or IPPROTO_TLS.
|
||||
func (s *StackBerkeley) Socket(domain int, stype int, protocol int) (sockfd int, _ error) {
|
||||
if domain != _AF_INET {
|
||||
if domain != AF_INET {
|
||||
return -1, fmt.Errorf("unsupported domain %d", domain)
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -117,23 +114,23 @@ func (s *StackBerkeley) Connect(sockfd int, host string, ip netip.AddrPort) erro
|
||||
var raddr net.Addr
|
||||
var network string
|
||||
var family, sotype int
|
||||
switch pending.sock.protocol {
|
||||
case _IPPROTO_TCP, _IPPROTO_TLS:
|
||||
switch lneto.IPProto(pending.sock.protocol) {
|
||||
case lneto.IPProtoTCP, _IPPROTO_TLS:
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
raddr = &net.TCPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
|
||||
network = "tcp4"
|
||||
family = _AF_INET
|
||||
sotype = _SOCK_STREAM
|
||||
case _IPPROTO_UDP:
|
||||
family = AF_INET
|
||||
sotype = SOCK_STREAM
|
||||
case lneto.IPProtoUDP:
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.UDPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
raddr = &net.UDPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
|
||||
network = "udp4"
|
||||
family = _AF_INET
|
||||
sotype = _SOCK_DGRAM
|
||||
family = AF_INET
|
||||
sotype = SOCK_DGRAM
|
||||
default:
|
||||
return fmt.Errorf("Connect: unsupported protocol %d", pending.sock.protocol)
|
||||
}
|
||||
@@ -166,7 +163,7 @@ func (s *StackBerkeley) Listen(sockfd int, backlog int) error {
|
||||
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
|
||||
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
|
||||
}
|
||||
c, err := s.gosocket(context.Background(), "tcp4", _AF_INET, _SOCK_STREAM, laddr, nil)
|
||||
c, err := s.gosocket(context.Background(), "tcp4", AF_INET, SOCK_STREAM, laddr, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+3
-12
@@ -162,11 +162,9 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
|
||||
return nil, err
|
||||
}
|
||||
uc := udpconn{
|
||||
Conn: &conn,
|
||||
// TODO: use udpaddr until UDPAddrFromAddrPort added to tinygo.
|
||||
// https://github.com/tinygo-org/net/issues/45
|
||||
localAddr: udpaddr(laddr),
|
||||
raddr: udpaddr(raddr),
|
||||
Conn: &conn,
|
||||
localAddr: net.UDPAddrFromAddrPort(laddr),
|
||||
raddr: net.UDPAddrFromAddrPort(raddr),
|
||||
}
|
||||
return uc, nil
|
||||
case "tcp", "tcp4", "tcp6":
|
||||
@@ -384,13 +382,6 @@ func (c udpconn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
func (c udpconn) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
return c.Conn.Write(b) // connected UDP: always writes to dialed remote
|
||||
}
|
||||
func udpaddr(addr netip.AddrPort) net.Addr {
|
||||
return &net.UDPAddr{
|
||||
IP: addr.Addr().AsSlice(),
|
||||
Zone: addr.Addr().Zone(),
|
||||
Port: int(addr.Port()),
|
||||
}
|
||||
}
|
||||
|
||||
// parseNetAddr converts a [net.Addr] to a [netip.AddrPort]. A nil or empty IP
|
||||
// (e.g. ":22" from a listen address with no host) is treated as 0.0.0.0 so
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
)
|
||||
|
||||
func TestARPLocal(t *testing.T) {
|
||||
@@ -20,7 +21,7 @@ func TestARPLocal(t *testing.T) {
|
||||
addr2 := netip.AddrPortFrom(netip.AddrFrom4(s2.Addr4()), 80) // listener, server.
|
||||
err := s1.AssimilateDHCPResults(&DHCPResults{
|
||||
Router: netip.AddrFrom4([4]byte{10, 0, 0, 255}),
|
||||
BroadcastAddr: netip.AddrFrom4([4]byte{255, 255, 255, 255}),
|
||||
BroadcastAddr: netip.AddrFrom4(ipv4.BroadcastAddr()),
|
||||
AssignedAddr4: s1.Addr4(),
|
||||
Subnet: netip.PrefixFrom(netip.AddrFrom4(s2.Addr4()), 24), // Subnet containing s2 will force an ARP on s1.
|
||||
TRenewal: 1000,
|
||||
|
||||
@@ -41,7 +41,7 @@ func BenchmarkARPExchange(b *testing.B) {
|
||||
var buf [frameSize]byte
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
err = c1.StartResolveHardwareAddress6(queryAddr)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
@@ -130,7 +130,7 @@ func BenchmarkTCPHandshake(b *testing.B) {
|
||||
var pktbuf [frameSize]byte
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for b.Loop() {
|
||||
// Setup connections.
|
||||
err = sv.ListenTCP4(svconn, svPort)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/ethernet"
|
||||
"github.com/soypat/lneto/internal"
|
||||
"github.com/soypat/lneto/internal/ltesto"
|
||||
"github.com/soypat/lneto/ipv4"
|
||||
"github.com/soypat/lneto/tcp"
|
||||
)
|
||||
@@ -120,7 +121,7 @@ func TestTCPListener_ConcurrentEcho(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(clientID int) {
|
||||
defer wg.Done()
|
||||
if runClient(t, clientID, &clientStacks[clientID], &clientConns[clientID],
|
||||
if runClient(t, ctx, clientID, &clientStacks[clientID], &clientConns[clientID],
|
||||
serverIP, serverPort) {
|
||||
clientSuccess[clientID] = true
|
||||
}
|
||||
@@ -206,7 +207,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
|
||||
}
|
||||
|
||||
if listener.NumberOfReadyToAccept() == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
runtime.Gosched()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -240,7 +241,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
|
||||
}
|
||||
}
|
||||
|
||||
func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
func runClient(t *testing.T, ctx context.Context, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
serverAddr netip.Addr, serverPort uint16) bool {
|
||||
// Dial server.
|
||||
clientPort := uint16(10000 + id)
|
||||
@@ -250,14 +251,8 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait for connection established (handshake via kernel loop).
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for conn.State() != tcp.StateEstablished {
|
||||
if time.Now().After(deadline) {
|
||||
t.Errorf("client %d: timeout waiting for established state, got %s", id, conn.State())
|
||||
return false
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
for conn.State() != tcp.StateEstablished && ctx.Err() == nil {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Send test data.
|
||||
@@ -268,15 +263,11 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
|
||||
return false
|
||||
}
|
||||
|
||||
// Read echo response.
|
||||
// Read echo response. conn.Read yields (backoffYield) until data arrives, and the
|
||||
// overall test timeout (ctx) backstops a hang.
|
||||
var buf [64]byte
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
var totalRead int
|
||||
for totalRead < len(testData) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Errorf("client %d: timeout waiting for echo response, got %d/%d bytes", id, totalRead, len(testData))
|
||||
return false
|
||||
}
|
||||
for totalRead < len(testData) && ctx.Err() == nil {
|
||||
n, err := conn.Read(buf[totalRead:])
|
||||
if err != nil {
|
||||
t.Errorf("client %d read failed: %v", id, err)
|
||||
@@ -325,11 +316,24 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug - 99,
|
||||
}))
|
||||
// When the payload exceeds the Tx buffer, c1.Write must run in a background
|
||||
// goroutine that blocks until the driver drains the buffer. The scheduler turns
|
||||
// that blocking into a deterministic, sleep-free handoff: c1's backoff parks the
|
||||
// writer and the driver releases it after freeing buffer space.
|
||||
async := datalen > tx1Buf
|
||||
var tsched *ltesto.Sched
|
||||
var tgoro ltesto.SchedGoro
|
||||
c1Backoff := backoffYield
|
||||
if async {
|
||||
tsched = ltesto.NewSched(t)
|
||||
tgoro = tsched.Goro()
|
||||
c1Backoff = tgoro.Yield
|
||||
}
|
||||
err := c1.Configure(tcp.ConnConfig{
|
||||
RxBuf: nil,
|
||||
TxBuf: make([]byte, tx1Buf),
|
||||
TxPacketQueueSize: queueSize,
|
||||
RWBackoff: backoffYield,
|
||||
RWBackoff: c1Backoff,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -350,29 +354,19 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
for i := range datalen {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
deadline := time.Now().Add(3600 * time.Second)
|
||||
err = c1.SetDeadline(deadline)
|
||||
err2 := c2.SetDeadline(deadline)
|
||||
if err != nil || err2 != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
async := datalen > tx1Buf
|
||||
if async {
|
||||
// Since data does not fit in TCP Tx buffer the test must be run asynchronously.
|
||||
c1.InternalHandler().SetLoggers(logger, logger)
|
||||
// c1.InternalHandler().SetLoggers(nil, nil)
|
||||
go func() {
|
||||
n, err := c1.Write(data)
|
||||
if err != nil {
|
||||
t.Error("async write", err)
|
||||
} else if n != len(data) {
|
||||
t.Error("io.Writer faulty implementation")
|
||||
n, werr := c1.Write(data)
|
||||
if werr == nil && n != len(data) {
|
||||
werr = fmt.Errorf("async write %d of %d bytes", n, len(data))
|
||||
}
|
||||
err = c1.Close()
|
||||
if err != nil {
|
||||
t.Fatal("async close", err)
|
||||
if werr == nil {
|
||||
werr = c1.Close()
|
||||
}
|
||||
tgoro.FinishWithErr(werr)
|
||||
}()
|
||||
} else {
|
||||
n, err := c1.Write(data)
|
||||
@@ -389,7 +383,20 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
exchanging := 1
|
||||
tcpData := 0
|
||||
totalRead := 0
|
||||
writerDone := false
|
||||
for exchanging > 0 || c1.State().TxDataOpen() {
|
||||
if async && !writerDone {
|
||||
// Block until the writer parks on a full Tx buffer (or finishes). Servicing
|
||||
// each park with exactly one pump round below keeps progress deterministic
|
||||
// without sleeping or guessing whether the writer will park again.
|
||||
done, werr := tsched.AwaitGoroYieldOrDone()
|
||||
if werr != nil {
|
||||
t.Error("async write/close:", werr)
|
||||
}
|
||||
if done {
|
||||
writerDone = true
|
||||
}
|
||||
}
|
||||
exchanges++
|
||||
exchanging = exchangeEthernetOnce(t, s1, s2, buf)
|
||||
frm, ok := getTCPFrame(buf[:exchanging])
|
||||
@@ -401,18 +408,21 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else if ngot != n {
|
||||
t.Errorf("want %d data read c1->c1, got %d", n, ngot)
|
||||
t.Errorf("want %d data read c1->c2, got %d", n, ngot)
|
||||
} else if !internal.BytesEqual(buf[:n], data[totalRead:totalRead+n]) {
|
||||
t.Errorf("exch%d data rx mismatch, want:\n%q\ngot:\n%q\n", exchanges, data[totalRead:totalRead+n], buf[:n])
|
||||
}
|
||||
totalRead += ngot
|
||||
runtime.Gosched() // Yield to let c1 write via goroutine.
|
||||
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way.
|
||||
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way, freeing its Tx buffer.
|
||||
if acks == 0 {
|
||||
t.Error("no data sent back to s1")
|
||||
}
|
||||
}
|
||||
}
|
||||
if async && !writerDone {
|
||||
// The ACK above freed Tx buffer space; release the writer to fill it and re-park.
|
||||
tsched.YieldToGoro()
|
||||
}
|
||||
}
|
||||
if c1.BufferedUnsent() != 0 {
|
||||
t.Errorf("done %s: want no data left unsent got %d/%d", c1.State(), c1.BufferedUnsent(), len(data))
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
package xnet
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
var _pcap CapturePrinter
|
||||
|
||||
@@ -14,4 +18,7 @@ func init() {
|
||||
|
||||
func debugPacket(msg string, b []byte) {
|
||||
_pcap.PrintEthernet(msg, b)
|
||||
if internal.HeapAllocDebugging {
|
||||
internal.LogAllocs(msg)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user