From fd2a9ede9125ceb0dbe42c238b79b72f6ec38b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Tue, 25 Aug 2026 17:51:11 +0200 Subject: [PATCH 1/3] feat: api plumbing and wazero wasm engine --- cmd/bee/cmd/cmd.go | 16 + cmd/bee/cmd/start.go | 8 + go.mod | 4 +- go.sum | 6 +- openapi/Swarm.yaml | 89 ++++- openapi/SwarmCommon.yaml | 93 ++++- pkg/api/api.go | 16 + pkg/api/api_test.go | 5 + pkg/api/execute.go | 263 ++++++++++++++ pkg/api/execute_test.go | 523 +++++++++++++++++++++++++++ pkg/api/router.go | 19 + pkg/compute/compute.go | 87 +++++ pkg/compute/compute_test.go | 306 ++++++++++++++++ pkg/compute/engine.go | 92 +++++ pkg/compute/limits.go | 38 ++ pkg/compute/testdata/README.md | 11 + pkg/compute/testdata/badimport.wasm | Bin 0 -> 62 bytes pkg/compute/testdata/badimport.wat | 4 + pkg/compute/testdata/bigmem.wasm | Bin 0 -> 126 bytes pkg/compute/testdata/bigmem.wat | 9 + pkg/compute/testdata/echo.wasm | Bin 0 -> 187 bytes pkg/compute/testdata/echo.wat | 15 + pkg/compute/testdata/entrypoint.wasm | Bin 0 -> 137 bytes pkg/compute/testdata/entrypoint.wat | 9 + pkg/compute/testdata/exit1.wasm | Bin 0 -> 82 bytes pkg/compute/testdata/exit1.wat | 4 + pkg/compute/testdata/infloop.wasm | Bin 0 -> 41 bytes pkg/compute/testdata/infloop.wat | 4 + pkg/compute/testdata/method.wasm | Bin 0 -> 238 bytes pkg/compute/testdata/method.wat | 26 ++ pkg/compute/testdata/trap.wasm | Bin 0 -> 37 bytes pkg/compute/testdata/trap.wat | 3 + pkg/compute/testdata/writer.wasm | Bin 0 -> 134 bytes pkg/compute/testdata/writer.wat | 9 + pkg/compute/wazero.go | 179 +++++++++ pkg/node/node.go | 41 +++ 36 files changed, 1874 insertions(+), 5 deletions(-) create mode 100644 pkg/api/execute.go create mode 100644 pkg/api/execute_test.go create mode 100644 pkg/compute/compute.go create mode 100644 pkg/compute/compute_test.go create mode 100644 pkg/compute/engine.go create mode 100644 pkg/compute/limits.go create mode 100644 pkg/compute/testdata/README.md create mode 100644 pkg/compute/testdata/badimport.wasm create mode 100644 pkg/compute/testdata/badimport.wat create mode 100644 pkg/compute/testdata/bigmem.wasm create mode 100644 pkg/compute/testdata/bigmem.wat create mode 100644 pkg/compute/testdata/echo.wasm create mode 100644 pkg/compute/testdata/echo.wat create mode 100644 pkg/compute/testdata/entrypoint.wasm create mode 100644 pkg/compute/testdata/entrypoint.wat create mode 100644 pkg/compute/testdata/exit1.wasm create mode 100644 pkg/compute/testdata/exit1.wat create mode 100644 pkg/compute/testdata/infloop.wasm create mode 100644 pkg/compute/testdata/infloop.wat create mode 100644 pkg/compute/testdata/method.wasm create mode 100644 pkg/compute/testdata/method.wat create mode 100644 pkg/compute/testdata/trap.wasm create mode 100644 pkg/compute/testdata/trap.wat create mode 100644 pkg/compute/testdata/writer.wasm create mode 100644 pkg/compute/testdata/writer.wat create mode 100644 pkg/compute/wazero.go diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index 59a8a8297e3..b83f7d41072 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -86,6 +86,14 @@ const ( optionNameMinimumGasTipCap = "minimum-gas-tip-cap" optionNameGasLimitFallback = "gas-limit-fallback" optionNameP2PWSSEnable = "p2p-wss-enable" + optionNameWasmExecuteEnable = "wasm-execute-enable" + optionNameWasmWorkers = "wasm-workers" + optionNameWasmExecTimeout = "wasm-exec-timeout" + optionNameWasmMaxModuleSize = "wasm-max-module-size" + optionNameWasmFuel = "wasm-fuel" + optionNameWasmMaxFuel = "wasm-max-fuel" + optionNameWasmMemory = "wasm-memory" + optionNameWasmMaxMemory = "wasm-max-memory" optionP2PWSSAddr = "p2p-wss-addr" optionNATWSSAddr = "nat-wss-addr" optionAutoTLSDomain = "autotls-domain" @@ -336,6 +344,14 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().Uint64(optionNameMinimumGasTipCap, 0, "minimum gas tip cap in wei for transactions, 0 means use suggested gas tip cap") cmd.Flags().Uint64(optionNameGasLimitFallback, 500_000, "gas limit fallback when estimation fails for contract transactions") cmd.Flags().Bool(optionNameP2PWSSEnable, false, "Enable Secure WebSocket P2P connections") + cmd.Flags().Bool(optionNameWasmExecuteEnable, false, "enable the experimental WASM execute endpoint") + cmd.Flags().Int(optionNameWasmWorkers, 0, "maximum number of concurrent WASM executions, 0 means min(number of CPUs, 8)") + cmd.Flags().Duration(optionNameWasmExecTimeout, 10*time.Second, "wall-clock watchdog timeout for a single WASM execution") + cmd.Flags().Uint64(optionNameWasmMaxModuleSize, 16*1024*1024, "maximum size in bytes of a WASM module that may be executed") + cmd.Flags().Uint64(optionNameWasmFuel, 100_000_000, "default fuel (gas) budget for a single WASM execution") + cmd.Flags().Uint64(optionNameWasmMaxFuel, 1_000_000_000, "maximum fuel (gas) budget a request may ask for") + cmd.Flags().Uint64(optionNameWasmMemory, 32*1024*1024, "default linear memory limit in bytes for a single WASM execution") + cmd.Flags().Uint64(optionNameWasmMaxMemory, 256*1024*1024, "maximum linear memory limit in bytes a request may ask for") cmd.Flags().String(optionP2PWSSAddr, ":1635", "p2p wss address") cmd.Flags().String(optionNATWSSAddr, "", "WSS NAT exposed address") cmd.Flags().String(optionAutoTLSDomain, p2pforge.DefaultForgeDomain, "autotls domain") diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index a423c6bbcaf..7ca192ec5ed 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -363,6 +363,14 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo WarmupTime: c.config.GetDuration(optionWarmUpTime), WelcomeMessage: c.config.GetString(optionWelcomeMessage), WhitelistedWithdrawalAddress: c.config.GetStringSlice(optionNameWhitelistedWithdrawalAddress), + WasmExecuteEnable: c.config.GetBool(optionNameWasmExecuteEnable), + WasmWorkers: c.config.GetInt(optionNameWasmWorkers), + WasmExecTimeout: c.config.GetDuration(optionNameWasmExecTimeout), + WasmMaxModuleSize: c.config.GetUint64(optionNameWasmMaxModuleSize), + WasmFuel: c.config.GetUint64(optionNameWasmFuel), + WasmMaxFuel: c.config.GetUint64(optionNameWasmMaxFuel), + WasmMemory: c.config.GetUint64(optionNameWasmMemory), + WasmMaxMemory: c.config.GetUint64(optionNameWasmMaxMemory), }) return b, err diff --git a/go.mod b/go.mod index 80b5129f2ac..e1916d38d9f 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( golang.org/x/crypto v0.48.0 golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.44.0 golang.org/x/term v0.40.0 golang.org/x/time v0.12.0 gopkg.in/yaml.v2 v2.4.0 @@ -59,6 +59,8 @@ require ( resenje.org/web v0.4.3 ) +require github.com/tetratelabs/wazero v1.12.0 + require ( filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect diff --git a/go.sum b/go.sum index be253587a4f..d9fad93eb11 100644 --- a/go.sum +++ b/go.sum @@ -956,6 +956,8 @@ github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cb github.com/tdewolff/minify/v2 v2.7.3/go.mod h1:BkDSm8aMMT0ALGmpt7j3Ra7nLUgZL0qhyrAHXwxcy5w= github.com/tdewolff/parse/v2 v2.4.2/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= @@ -1260,8 +1262,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index a0b3e296b8f..e7ef9e164f4 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: - version: 8.1.0 + version: 8.2.0 title: Bee API description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management" @@ -211,6 +211,93 @@ paths: default: description: Default response + "/@/{address}": + parameters: + - in: path + name: address + schema: + $ref: "SwarmCommon.yaml#/components/schemas/SwarmReference" + required: true + description: Swarm address reference of the WASM module + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmFuelLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmMemoryLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmEntrypoint" + get: &executeOperation + summary: "Execute a WASM module stored in Swarm" + description: > + Downloads the WebAssembly module at the given address, runs it in a + sandbox with the request body as its input and returns what it wrote as + output. Disabled by default; the node operator enables it with + `--wasm-execute-enable`. + + + Every HTTP method is accepted, including ones not listed here, and the + module decides how to react to it: the method is handed to the module as + the `REQUEST_METHOD` environment variable, following CGI convention. The + one exception is `OPTIONS`, which the node answers itself as a CORS + preflight so it never reaches untrusted code. + + + The representation of the result is negotiated with the `Accept` header: + `application/json` (the default) returns the full execution envelope, + `application/octet-stream` and `text/html` return the raw output. Any + other media type is rejected with 406. + + + A verdict on the program itself (`trap`, `invalid-module`) is reported + as 400 with the verdict in the `swarm-wasm-status` header; a failure + local to this node is reported as 500. + tags: + - Execute + requestBody: + required: false + description: Input handed to the module + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: The module ran and produced a result + headers: + "swarm-wasm-status": + $ref: "SwarmCommon.yaml#/components/headers/SwarmWasmStatus" + "swarm-wasm-fuel-consumed": + $ref: "SwarmCommon.yaml#/components/headers/SwarmWasmFuelConsumed" + content: + application/octet-stream: + schema: + type: string + format: binary + text/html: + schema: + type: string + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/WasmExecutionResponse" + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + "403": + $ref: "SwarmCommon.yaml#/components/responses/403" + "404": + $ref: "SwarmCommon.yaml#/components/responses/404" + "406": + $ref: "SwarmCommon.yaml#/components/responses/406" + "413": + $ref: "SwarmCommon.yaml#/components/responses/413" + "429": + $ref: "SwarmCommon.yaml#/components/responses/429" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + default: + description: Default response + head: *executeOperation + post: *executeOperation + put: *executeOperation + patch: *executeOperation + delete: *executeOperation + "/bytes/{address}": get: summary: "Retrieve data by reference" diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ffcbbac3b8e..44f49162048 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 5.0.0 + version: 5.1.0 title: Common Data Types description: Common data structures and types used throughout the Bee API @@ -656,6 +656,35 @@ components: pattern: "^[A-Fa-f0-9]{64}$" example: "36b7efd913ca4cf880b8eeac5093fa27b0825906c600685b6abdd6566e6cfe8f" + WasmStatus: + type: string + description: > + Outcome of a WASM execution. `ok`, `out-of-fuel`, `trap` and + `invalid-module` are verdicts on the program itself; `host-error` is a + failure local to the node and says nothing about the program. + enum: + - ok + - out-of-fuel + - trap + - invalid-module + - host-error + + WasmExecutionResponse: + type: object + properties: + status: + $ref: "#/components/schemas/WasmStatus" + output: + type: string + format: byte + description: Bytes the module wrote to its output + fuelConsumed: + type: integer + format: int64 + trapMessage: + type: string + description: Explanation of a non-`ok` status + PublicKey: type: string pattern: "^[A-Fa-f0-9]{66}$" @@ -1081,6 +1110,17 @@ components: schema: $ref: "SwarmCommon.yaml#/components/schemas/Uid" + SwarmWasmStatus: + description: "Outcome of the execution" + schema: + $ref: "#/components/schemas/WasmStatus" + + SwarmWasmFuelConsumed: + description: "Amount of fuel (gas) the execution consumed" + schema: + type: integer + format: int64 + SwarmFeedIndex: description: "The index of the found update" schema: @@ -1288,6 +1328,39 @@ components: description: > Indicates whether the uploaded data should be sent to the network immediately or deferred. Default: deferred (true) + SwarmWasmFuelLimit: + in: header + name: swarm-wasm-fuel-limit + schema: + type: integer + format: int64 + required: false + description: > + Fuel (gas) budget for the execution. Values above the limit configured + on the node are clamped to it. Defaults to the node configured value. + + SwarmWasmMemoryLimit: + in: header + name: swarm-wasm-memory-limit + schema: + type: integer + format: int64 + required: false + description: > + Maximum linear memory in bytes the module may allocate. Values above the + limit configured on the node are clamped to it. Defaults to the node + configured value. + + SwarmWasmEntrypoint: + in: header + name: swarm-wasm-entrypoint + schema: + type: string + required: false + description: > + Exported function to invoke. Defaults to the WASI command entrypoint + `_start`. + SwarmCache: in: header name: swarm-cache @@ -1360,6 +1433,24 @@ components: application/problem+json: schema: $ref: "#/components/schemas/ProblemDetails" + "403": + description: Forbidden + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "406": + description: Not Acceptable + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" + "413": + description: Payload Too Large + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetails" "429": description: Too many requests content: diff --git a/pkg/api/api.go b/pkg/api/api.go index acd838a3ff6..6686258eb80 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -27,6 +27,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/accounting" + "github.com/ethersphere/bee/v2/pkg/compute" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds" "github.com/ethersphere/bee/v2/pkg/file/pipeline" @@ -94,12 +95,19 @@ const ( SwarmActPublisherHeader = "Swarm-Act-Publisher" SwarmActHistoryAddressHeader = "Swarm-Act-History-Address" + SwarmWasmFuelLimitHeader = "Swarm-Wasm-Fuel-Limit" + SwarmWasmMemoryLimitHeader = "Swarm-Wasm-Memory-Limit" + SwarmWasmEntrypointHeader = "Swarm-Wasm-Entrypoint" + SwarmWasmStatusHeader = "Swarm-Wasm-Status" + SwarmWasmFuelConsumedHeader = "Swarm-Wasm-Fuel-Consumed" + ImmutableHeader = "Immutable" GasPriceHeader = "Gas-Price" GasLimitHeader = "Gas-Limit" ETagHeader = "ETag" AuthorizationHeader = "Authorization" + AcceptHeader = "Accept" AcceptEncodingHeader = "Accept-Encoding" ContentTypeHeader = "Content-Type" ContentDispositionHeader = "Content-Disposition" @@ -194,6 +202,9 @@ type Service struct { stamperStore storage.Store pinIntegrity PinIntegrity + compute compute.Engine + executeConfig ExecuteConfig + syncStatus func() (bool, error) swap swap.Interface @@ -268,6 +279,8 @@ type ExtraOptions struct { SyncStatus func() (bool, error) NodeStatus *status.Service PinIntegrity PinIntegrity + Compute compute.Engine + ExecuteConfig ExecuteConfig } func New( @@ -379,6 +392,9 @@ func (s *Service) Configure(signer crypto.Signer, tracer *tracing.Tracer, o Opti } s.pinIntegrity = e.PinIntegrity + + s.compute = e.Compute + s.executeConfig = e.ExecuteConfig } func (s *Service) SetProbe(probe *Probe) { diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index babd816dd06..348dd1dd765 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -26,6 +26,7 @@ import ( mockac "github.com/ethersphere/bee/v2/pkg/accesscontrol/mock" accountingmock "github.com/ethersphere/bee/v2/pkg/accounting/mock" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds" "github.com/ethersphere/bee/v2/pkg/file/pipeline" @@ -131,6 +132,8 @@ type testServerOptions struct { RedistributionAgent *storageincentives.Agent NodeStatus *status.Service PinIntegrity api.PinIntegrity + Compute compute.Engine + ExecuteConfig api.ExecuteConfig WhitelistedAddr string FullAPIDisabled bool ChequebookDisabled bool @@ -210,6 +213,8 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket. Staking: o.StakingContract, NodeStatus: o.NodeStatus, PinIntegrity: o.PinIntegrity, + Compute: o.Compute, + ExecuteConfig: o.ExecuteConfig, } // By default bee mode is set to full mode. diff --git a/pkg/api/execute.go b/pkg/api/execute.go new file mode 100644 index 00000000000..ae21d7efb45 --- /dev/null +++ b/pkg/api/execute.go @@ -0,0 +1,263 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/file/joiner" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/ethersphere/bee/v2/pkg/topology" + "github.com/ethersphere/bee/v2/pkg/tracing" + "github.com/gorilla/mux" +) + +// ExecuteConfig holds the operator-configured bounds for the execute endpoint. +// Per-request headers may only lower a limit below its configured maximum. +type ExecuteConfig struct { + MaxModuleSize uint64 + DefaultFuel uint64 + MaxFuel uint64 + DefaultMemory uint64 + MaxMemory uint64 +} + +// executeResponse is the structured (JSON) representation of an execution result. +type executeResponse struct { + Status string `json:"status"` + Output []byte `json:"output"` + FuelConsumed uint64 `json:"fuelConsumed"` + TrapMessage string `json:"trapMessage,omitempty"` +} + +// executeHandler downloads the WASM module addressed by {address}, runs it in the +// sandbox with the request body as input, and renders the result negotiated on +// the Accept header. +// +// The route accepts every HTTP method and hands the method to the module (see +// compute.Request.Method), so the program decides how to react to it. OPTIONS is +// the one exception: it is answered by the node so CORS preflight never reaches +// untrusted code. +func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { + logger := tracing.NewLoggerWithTraceID(r.Context(), s.logger.WithName("execute").Build()) + + // We don't allow the OPTIONS method, as browsers use it for CORS preflight checks. + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + paths := struct { + Address swarm.Address `map:"address,resolve" validate:"required"` + }{} + if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + response("invalid path params", logger, w) + return + } + + headers := struct { + Fuel *uint64 `map:"Swarm-Wasm-Fuel-Limit"` + Memory *uint64 `map:"Swarm-Wasm-Memory-Limit"` + Entrypoint string `map:"Swarm-Wasm-Entrypoint"` + }{} + if response := s.mapStructure(r.Header, &headers); response != nil { + response("invalid header params", logger, w) + return + } + + // Negotiate the response representation up front so we can reject an + // unsupported Accept before doing any work. + format, ok := negotiateExecuteFormat(r.Header.Get(AcceptHeader)) + if !ok { + jsonhttp.NotAcceptable(w, "unsupported Accept media type") + return + } + + lim := compute.Limits{ + Fuel: clampLimit(headers.Fuel, s.executeConfig.DefaultFuel, s.executeConfig.MaxFuel), + Memory: clampLimit(headers.Memory, s.executeConfig.DefaultMemory, s.executeConfig.MaxMemory), + Entrypoint: headers.Entrypoint, + } + + // Download and reassemble the module bytes, capped at the configured maximum. + reader, l, err := joiner.New(r.Context(), s.storer.Download(true), s.storer.Cache(), paths.Address, redundancy.DefaultDownloadLevel) + if err != nil { + if errors.Is(err, storage.ErrNotFound) || errors.Is(err, topology.ErrNotFound) { + logger.Debug("execute: module not found", "address", paths.Address, "error", err) + jsonhttp.NotFound(w, "module not found") + return + } + logger.Debug("execute: joiner failed", "address", paths.Address, "error", err) + logger.Error(nil, "execute: joiner failed") + jsonhttp.InternalServerError(w, "could not read module") + return + } + + maxModule := s.executeConfig.MaxModuleSize + if maxModule > 0 && l >= 0 && uint64(l) > maxModule { + jsonhttp.RequestEntityTooLarge(w, "module exceeds maximum size") + return + } + + module, err := readCapped(reader, maxModule) + if err != nil { + if errors.Is(err, errTooLarge) { + jsonhttp.RequestEntityTooLarge(w, "module exceeds maximum size") + return + } + logger.Debug("execute: reading module failed", "address", paths.Address, "error", err) + logger.Error(nil, "execute: reading module failed") + jsonhttp.InternalServerError(w, "could not read module") + return + } + + input, err := io.ReadAll(r.Body) + if err != nil { + logger.Debug("execute: reading request body failed", "error", err) + jsonhttp.InternalServerError(w, "could not read input") + return + } + + result, err := s.compute.Execute(r.Context(), compute.Request{ + Module: module, + Method: r.Method, + Input: input, + Limits: lim, + }) + if err != nil { + if errors.Is(err, compute.ErrBusy) { + jsonhttp.TooManyRequests(w, "execution workers busy") + return + } + logger.Debug("execute: execution failed", "address", paths.Address, "error", err) + logger.Error(nil, "execute: execution failed") + jsonhttp.InternalServerError(w, "execution failed") + return + } + + renderExecResult(w, format, result) +} + +// renderExecResult writes the execution result in the negotiated representation. +// The HTTP status is derived from the program verdict and is independent of the +// chosen format. +func renderExecResult(w http.ResponseWriter, format string, res compute.Result) { + w.Header().Set(SwarmWasmStatusHeader, res.Status.String()) + w.Header().Set(SwarmWasmFuelConsumedHeader, strconv.FormatUint(res.FuelConsumed, 10)) + + switch res.Status { + case compute.StatusInvalidModule, compute.StatusTrap: + // Program's fault: deterministic bad request. + jsonhttp.BadRequest(w, execErrorBody(format, res)) + return + case compute.StatusHostError: + jsonhttp.InternalServerError(w, "execution failed") + return + } + + // StatusOK or StatusOutOfFuel: 200. For out-of-fuel there may be no output. + switch format { + case formatJSON: + jsonhttp.OK(w, executeResponse{ + Status: res.Status.String(), + Output: res.Output, + FuelConsumed: res.FuelConsumed, + TrapMessage: res.TrapMessage, + }) + case formatHTML: + w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(res.Output) + default: // formatOctet + w.Header().Set(ContentTypeHeader, "application/octet-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(res.Output) + } +} + +// execErrorBody builds the body for a deterministic program-fault response. For +// JSON it returns the structured envelope; otherwise a short message string. +func execErrorBody(format string, res compute.Result) interface{} { + if format == formatJSON { + return executeResponse{ + Status: res.Status.String(), + Output: res.Output, + FuelConsumed: res.FuelConsumed, + TrapMessage: res.TrapMessage, + } + } + msg := res.Status.String() + if res.TrapMessage != "" { + msg += ": " + res.TrapMessage + } + return msg +} + +const ( + formatOctet = "octet" + formatJSON = "json" + formatHTML = "html" +) + +// negotiateExecuteFormat picks a response representation from the Accept header. +// It returns false when the client requires a media type we do not support. +func negotiateExecuteFormat(accept string) (string, bool) { + accept = strings.TrimSpace(accept) + if accept == "" { + return formatJSON, true + } + for _, part := range strings.Split(accept, ",") { + // Drop any parameters (e.g. q-values); we do not rank by quality. + mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + switch mediaType { + case "application/json", "*/*", "application/*": + return formatJSON, true + case "text/html", "application/xhtml+xml": + return formatHTML, true + case "application/octet-stream": + return formatOctet, true + } + } + return "", false +} + +// clampLimit resolves a per-request override against the configured default and +// maximum. A nil override uses the default; any value is capped at the maximum. +func clampLimit(override *uint64, def, maximum uint64) uint64 { + v := def + if override != nil { + v = *override + } + if maximum > 0 && v > maximum { + v = maximum + } + return v +} + +var errTooLarge = errors.New("data exceeds maximum size") + +// readCapped reads all bytes from r, failing with errTooLarge if the content +// exceeds maximum. A maximum of 0 means unlimited. +func readCapped(r io.Reader, maximum uint64) ([]byte, error) { + if maximum == 0 { + return io.ReadAll(r) + } + buf, err := io.ReadAll(io.LimitReader(r, int64(maximum)+1)) + if err != nil { + return nil, err + } + if uint64(len(buf)) > maximum { + return nil, errTooLarge + } + return buf, nil +} diff --git a/pkg/api/execute_test.go b/pkg/api/execute_test.go new file mode 100644 index 00000000000..fdcdb018016 --- /dev/null +++ b/pkg/api/execute_test.go @@ -0,0 +1,523 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" + "github.com/ethersphere/bee/v2/pkg/log" + mockpost "github.com/ethersphere/bee/v2/pkg/postage/mock" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// mockEngine is a compute.Engine that records what it was asked to run and +// replays a canned outcome. +type mockEngine struct { + result compute.Result + err error + request compute.Request + calls int +} + +func (m *mockEngine) Execute(_ context.Context, req compute.Request) (compute.Result, error) { + m.calls++ + m.request = req + return m.result, m.err +} + +func (m *mockEngine) Close() error { return nil } + +// uploadModule stores content through the bytes endpoint and returns its address. +func uploadModule(t *testing.T, client *http.Client, content []byte) swarm.Address { + t.Helper() + + var resp api.BytesPostResponse + jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(content)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + return resp.Reference +} + +func newExecuteTestServer(t *testing.T, engine compute.Engine, cfg api.ExecuteConfig) *http.Client { + t.Helper() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Logger: log.Noop, + Post: mockpost.New(mockpost.WithAcceptAll()), + Compute: engine, + ExecuteConfig: cfg, + }) + return client +} + +// TestExecute checks that the module is downloaded from Swarm, the request body +// is handed to the engine as input and the result comes back as raw bytes. +func TestExecute(t *testing.T) { + t.Parallel() + + module := []byte("this stands in for a wasm module") + engine := &mockEngine{result: compute.Result{ + Status: compute.StatusOK, + Output: []byte("computed output"), + FuelConsumed: 4711, + }} + + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, module) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + jsonhttptest.WithRequestBody(bytes.NewReader([]byte("the input"))), + jsonhttptest.WithExpectedResponse([]byte("computed output")), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmFuelConsumedHeader, "4711"), + ) + + if engine.calls != 1 { + t.Fatalf("got %d engine calls, want 1", engine.calls) + } + if !bytes.Equal(engine.request.Module, module) { + t.Errorf("got module %q, want %q", engine.request.Module, module) + } + if string(engine.request.Input) != "the input" { + t.Errorf("got input %q, want %q", engine.request.Input, "the input") + } +} + +func TestExecuteDisabled(t *testing.T) { + t.Parallel() + + client := newExecuteTestServer(t, nil, api.ExecuteConfig{}) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusForbidden, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "WASM execution is disabled. This endpoint is unavailable.", + Code: http.StatusForbidden, + }), + ) +} + +func TestExecuteModuleNotFound(t *testing.T) { + t.Parallel() + + engine := &mockEngine{} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusNotFound, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "module not found", + Code: http.StatusNotFound, + }), + ) + + if engine.calls != 0 { + t.Errorf("got %d engine calls, want none for a missing module", engine.calls) + } +} + +func TestExecuteModuleTooLarge(t *testing.T) { + t.Parallel() + + engine := &mockEngine{} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{MaxModuleSize: 8}) + addr := uploadModule(t, client, []byte("well over eight bytes")) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusRequestEntityTooLarge, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "module exceeds maximum size", + Code: http.StatusRequestEntityTooLarge, + }), + ) + + if engine.calls != 0 { + t.Errorf("got %d engine calls, want none for an oversized module", engine.calls) + } +} + +func TestExecuteBusy(t *testing.T) { + t.Parallel() + + engine := &mockEngine{err: compute.ErrBusy} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusTooManyRequests, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "execution workers busy", + Code: http.StatusTooManyRequests, + }), + ) +} + +func TestExecuteEngineFailure(t *testing.T) { + t.Parallel() + + engine := &mockEngine{err: errors.New("engine exploded")} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusInternalServerError, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "execution failed", + Code: http.StatusInternalServerError, + }), + ) +} + +// TestExecuteStatusMapping checks that a program verdict maps to a stable HTTP +// status: a bad module or a trap is the caller's fault, a host error is ours. +func TestExecuteStatusMapping(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + result compute.Result + wantHTTP int + wantStatus string + }{ + { + name: "ok", + result: compute.Result{Status: compute.StatusOK, Output: []byte("out")}, + wantHTTP: http.StatusOK, + wantStatus: "ok", + }, + { + name: "out of fuel", + result: compute.Result{Status: compute.StatusOutOfFuel}, + wantHTTP: http.StatusOK, + wantStatus: "out-of-fuel", + }, + { + name: "trap", + result: compute.Result{Status: compute.StatusTrap, TrapMessage: "unreachable"}, + wantHTTP: http.StatusBadRequest, + wantStatus: "trap", + }, + { + name: "invalid module", + result: compute.Result{Status: compute.StatusInvalidModule, TrapMessage: "invalid magic number"}, + wantHTTP: http.StatusBadRequest, + wantStatus: "invalid-module", + }, + { + name: "host error", + result: compute.Result{Status: compute.StatusHostError}, + wantHTTP: http.StatusInternalServerError, + wantStatus: "host-error", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: tc.result} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), tc.wantHTTP, + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, tc.wantStatus), + ) + }) + } +} + +// TestExecuteContentNegotiation checks the representation chosen for each Accept +// header, and that an unsupported one is rejected before the module is run. +func TestExecuteContentNegotiation(t *testing.T) { + t.Parallel() + + output := []byte("result") + + for _, tc := range []struct { + name string + accept string + wantHTTP int + wantType string + wantBody []byte + wantJSON bool + wantExecute bool + }{ + { + name: "no accept header defaults to json", + wantHTTP: http.StatusOK, + wantJSON: true, + wantExecute: true, + }, + { + name: "octet-stream", + accept: "application/octet-stream", + wantHTTP: http.StatusOK, + wantType: "application/octet-stream", + wantBody: output, + wantExecute: true, + }, + { + name: "wildcard defaults to json", + accept: "*/*", + wantHTTP: http.StatusOK, + wantJSON: true, + wantExecute: true, + }, + { + name: "html", + accept: "text/html", + wantHTTP: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantBody: output, + wantExecute: true, + }, + { + name: "json", + accept: "application/json", + wantHTTP: http.StatusOK, + wantJSON: true, + wantExecute: true, + }, + { + name: "first supported type wins", + accept: "image/png, application/json;q=0.9", + wantHTTP: http.StatusOK, + wantJSON: true, + wantExecute: true, + }, + { + name: "unsupported type", + accept: "image/png", + wantHTTP: http.StatusNotAcceptable, + wantJSON: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{ + Status: compute.StatusOK, + Output: output, + FuelConsumed: 42, + }} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + opts := []jsonhttptest.Option{} + if tc.accept != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept)) + } + + var body []byte + if tc.wantJSON || tc.wantHTTP == http.StatusNotAcceptable { + opts = append(opts, jsonhttptest.WithPutResponseBody(&body)) + } else { + opts = append(opts, + jsonhttptest.WithExpectedResponse(tc.wantBody), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType), + ) + } + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), tc.wantHTTP, opts...) + + if tc.wantJSON { + var resp struct { + Status string `json:"status"` + Output []byte `json:"output"` + FuelConsumed uint64 `json:"fuelConsumed"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal response %q: %v", body, err) + } + if resp.Status != "ok" { + t.Errorf("got status %q, want %q", resp.Status, "ok") + } + if !bytes.Equal(resp.Output, output) { + t.Errorf("got output %q, want %q", resp.Output, output) + } + if resp.FuelConsumed != 42 { + t.Errorf("got fuel consumed %d, want 42", resp.FuelConsumed) + } + } + + if got := engine.calls > 0; got != tc.wantExecute { + t.Errorf("engine called: %v, want %v", got, tc.wantExecute) + } + }) + } +} + +// TestExecuteLimits checks that per-request limits are clamped to the operator +// configured maxima and fall back to the configured defaults. +func TestExecuteLimits(t *testing.T) { + t.Parallel() + + cfg := api.ExecuteConfig{ + DefaultFuel: 1000, + MaxFuel: 5000, + DefaultMemory: 2048, + MaxMemory: 8192, + } + + for _, tc := range []struct { + name string + fuel string + memory string + entrypoint string + want compute.Limits + }{ + { + name: "defaults", + want: compute.Limits{Fuel: 1000, Memory: 2048}, + }, + { + name: "request below the maximum is honored", + fuel: "200", + memory: "1024", + want: compute.Limits{Fuel: 200, Memory: 1024}, + }, + { + name: "request above the maximum is clamped", + fuel: "999999", + memory: "999999", + want: compute.Limits{Fuel: 5000, Memory: 8192}, + }, + { + name: "entrypoint is passed through", + entrypoint: "run", + want: compute.Limits{Fuel: 1000, Memory: 2048, Entrypoint: "run"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, cfg) + addr := uploadModule(t, client, []byte("module")) + + opts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + jsonhttptest.WithNoResponseBody(), + } + if tc.fuel != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmFuelLimitHeader, tc.fuel)) + } + if tc.memory != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmMemoryLimitHeader, tc.memory)) + } + if tc.entrypoint != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmEntrypointHeader, tc.entrypoint)) + } + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, opts...) + + if engine.request.Limits != tc.want { + t.Errorf("got limits %+v, want %+v", engine.request.Limits, tc.want) + } + }) + } +} + +func TestExecuteInvalidRequest(t *testing.T) { + t.Parallel() + + engine := &mockEngine{} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + + t.Run("invalid address", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodPost, "/@/not-an-address", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "invalid path params", + Code: http.StatusBadRequest, + Reasons: []jsonhttp.Reason{{ + Field: "address", + Error: api.HexInvalidByteError('n').Error(), + }}, + }), + ) + }) + + t.Run("invalid fuel limit", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmWasmFuelLimitHeader, "not a number"), + ) + }) + + if engine.calls != 0 { + t.Errorf("got %d engine calls, want none for an invalid request", engine.calls) + } +} + +// TestExecuteMethods checks that every HTTP method reaches the module and that +// the module is told which one it was called with. +func TestExecuteMethods(t *testing.T) { + t.Parallel() + + for _, method := range []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + "PROPFIND", + } { + t.Run(method, func(t *testing.T) { + t.Parallel() + + module := []byte("this stands in for a wasm module") + engine := &mockEngine{result: compute.Result{ + Status: compute.StatusOK, + Output: []byte("computed output"), + }} + + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, module) + + opts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + } + // A HEAD response carries no body, so only assert it elsewhere. + if method != http.MethodHead { + opts = append(opts, jsonhttptest.WithExpectedResponse([]byte("computed output"))) + } + jsonhttptest.Request(t, client, method, "/@/"+addr.String(), http.StatusOK, opts...) + + if engine.calls != 1 { + t.Fatalf("got %d engine calls, want 1", engine.calls) + } + if engine.request.Method != method { + t.Errorf("got method %q, want %q", engine.request.Method, method) + } + if !bytes.Equal(engine.request.Module, module) { + t.Errorf("got module %q, want %q", engine.request.Module, module) + } + }) + } +} + +// TestExecuteOptions checks that a CORS preflight is answered by the node and +// never reaches the module. +func TestExecuteOptions(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + + jsonhttptest.Request(t, client, http.MethodOptions, "/@/"+swarm.RandAddress(t).String(), http.StatusNoContent) + + if engine.calls != 0 { + t.Errorf("got %d engine calls, want none for a preflight", engine.calls) + } +} diff --git a/pkg/api/router.go b/pkg/api/router.go index 941c63fc89e..d3c3d6ed1d2 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -199,6 +199,16 @@ func (s *Service) checkSwapAvailability(handler http.Handler) http.Handler { }) } +func (s *Service) checkExecuteAvailability(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.compute == nil { + jsonhttp.Forbidden(w, "WASM execution is disabled. This endpoint is unavailable.") + return + } + handler.ServeHTTP(w, r) + }) +} + func (s *Service) checkChequebookAvailability(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.chequebookEnabled { @@ -262,6 +272,15 @@ func (s *Service) mountAPI() { ), }) + // Registered without a jsonhttp.MethodHandler on purpose: every HTTP method + // reaches the module, which is told which one it was called with. + handle("/@/{address}", web.ChainHandlers( + s.checkExecuteAvailability, + s.contentLengthMetricMiddleware(), + s.newTracingHandler("execute"), + web.FinalHandlerFunc(s.executeHandler), + )) + handle("/bytes/{address}", jsonhttp.MethodHandler{ "GET": web.ChainHandlers( s.contentLengthMetricMiddleware(), diff --git a/pkg/compute/compute.go b/pkg/compute/compute.go new file mode 100644 index 00000000000..e42ffccbb5b --- /dev/null +++ b/pkg/compute/compute.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute + +import ( + "context" + "errors" + "time" + + "github.com/ethersphere/bee/v2/pkg/log" + "golang.org/x/sync/semaphore" +) + +// loggerName is the tree path name of the logger for this package. +const loggerName = "compute" + +// ErrBusy is returned when all execution workers are occupied. +var ErrBusy = errors.New("compute: all workers busy") + +// Options configures the compute Service. +type Options struct { + // Workers bounds the number of concurrent executions. Values < 1 become 1. + Workers int + // Watchdog is a wall-clock safety timeout that kills a hung execution. It is + // NOT a deterministic budget (see fuel) and a kill yields StatusHostError. + Watchdog time.Duration + // Logger is used for operator diagnostics. + Logger log.Logger +} + +// Service is the node-facing execution service. It bounds concurrency and +// applies the watchdog around an Engine. +// +// Phase 0 delegates to an in-process wazero engine; the Engine boundary lets a +// later phase swap in the deterministic out-of-process worker without touching +// callers. +type Service struct { + engine Engine + sem *semaphore.Weighted + watchdog time.Duration + logger log.Logger +} + +// New constructs a compute Service. +func New(o Options) (*Service, error) { + if o.Workers < 1 { + o.Workers = 1 + } + logger := o.Logger + if logger == nil { + logger = log.Noop + } + logger = logger.WithName(loggerName).Register() + + logger.Warning("wasm execute is experimental: the phase-0 engine does not enforce deterministic gas and its output is not reproducible across nodes") + + return &Service{ + engine: newWazeroEngine(logger), + sem: semaphore.NewWeighted(int64(o.Workers)), + watchdog: o.Watchdog, + logger: logger, + }, nil +} + +// Execute runs a module, bounding concurrency and applying the watchdog timeout. +// It returns ErrBusy without blocking when no worker slot is free. +func (s *Service) Execute(ctx context.Context, req Request) (Result, error) { + if !s.sem.TryAcquire(1) { + return Result{}, ErrBusy + } + defer s.sem.Release(1) + + if s.watchdog > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.watchdog) + defer cancel() + } + + return s.engine.Execute(ctx, req) +} + +// Close releases engine resources. +func (s *Service) Close() error { + return s.engine.Close() +} diff --git a/pkg/compute/compute_test.go b/pkg/compute/compute_test.go new file mode 100644 index 00000000000..ec4040e11df --- /dev/null +++ b/pkg/compute/compute_test.go @@ -0,0 +1,306 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/compute" +) + +// loadModule reads a WASM fixture. See testdata/README.md for their sources. +func loadModule(t *testing.T, name string) []byte { + t.Helper() + + module, err := os.ReadFile(filepath.Join("testdata", name+".wasm")) + if err != nil { + t.Fatal(err) + } + return module +} + +func newService(t *testing.T, o compute.Options) *compute.Service { + t.Helper() + + if o.Watchdog == 0 { + o.Watchdog = 10 * time.Second + } + s, err := compute.New(o) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("close compute service: %v", err) + } + }) + return s +} + +func TestExecute(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + module string + input []byte + limits compute.Limits + want compute.Status + output string + }{ + { + name: "writes to stdout", + module: "writer", + want: compute.StatusOK, + output: "hello swarm", + }, + { + name: "input is echoed back", + module: "echo", + input: []byte("swarm input"), + want: compute.StatusOK, + output: "swarm input", + }, + { + name: "explicit entrypoint", + module: "entrypoint", + limits: compute.Limits{Entrypoint: "run"}, + want: compute.StatusOK, + output: "entrypoint output", + }, + { + name: "entrypoint not exported", + module: "writer", + limits: compute.Limits{Entrypoint: "missing"}, + want: compute.StatusInvalidModule, + }, + { + // Without an explicit entrypoint the module has no WASI command + // entry to run. + name: "no start function", + module: "entrypoint", + want: compute.StatusInvalidModule, + }, + { + name: "unreachable traps", + module: "trap", + want: compute.StatusTrap, + }, + { + name: "non-zero exit traps", + module: "exit1", + want: compute.StatusTrap, + }, + { + name: "unsupported import", + module: "badimport", + want: compute.StatusInvalidModule, + }, + { + name: "memory over the limit", + module: "bigmem", + limits: compute.Limits{Memory: 64 * 1024}, + want: compute.StatusInvalidModule, + }, + { + name: "memory within the limit", + module: "bigmem", + limits: compute.Limits{Memory: 16 * 1024 * 1024}, + want: compute.StatusOK, + output: "big", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, tc.module), + Input: tc.input, + Limits: tc.limits, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Status != tc.want { + t.Errorf("got status %v, want %v (trap message: %q)", res.Status, tc.want, res.TrapMessage) + } + if string(res.Output) != tc.output { + t.Errorf("got output %q, want %q", res.Output, tc.output) + } + if tc.want != compute.StatusOK && res.TrapMessage == "" { + t.Error("want a trap message explaining the verdict") + } + }) + } +} + +func TestExecuteInvalidModule(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{Module: []byte("this is not a wasm module")}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Status != compute.StatusInvalidModule { + t.Errorf("got status %v, want %v", res.Status, compute.StatusInvalidModule) + } +} + +// TestExecuteWatchdog checks that a module which never terminates is killed and +// reported as an infrastructure failure rather than as a program verdict: the +// kill depends on this node's wall clock, so it is not reproducible elsewhere. +func TestExecuteWatchdog(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1, Watchdog: 100 * time.Millisecond}) + + res, err := s.Execute(context.Background(), compute.Request{Module: loadModule(t, "infloop")}) + if err == nil { + t.Fatal("want an error for a watchdog kill") + } + if res.Status != compute.StatusHostError { + t.Errorf("got status %v, want %v", res.Status, compute.StatusHostError) + } +} + +func TestExecuteContextCanceled(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + res, err := s.Execute(ctx, compute.Request{Module: loadModule(t, "infloop")}) + if err == nil { + t.Fatal("want an error for a canceled execution") + } + if res.Status != compute.StatusHostError { + t.Errorf("got status %v, want %v", res.Status, compute.StatusHostError) + } +} + +func TestExecuteBusy(t *testing.T) { + t.Parallel() + + // With a single worker and a module that runs until the watchdog fires, + // whichever execution acquires the worker holds it for the duration, so the + // other must be rejected immediately instead of queueing behind it. + s := newService(t, compute.Options{Workers: 1, Watchdog: time.Second}) + module := loadModule(t, "infloop") + + errs := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + _, err := s.Execute(context.Background(), compute.Request{Module: module}) + errs <- err + }() + } + + var busy int + for i := 0; i < 2; i++ { + if errors.Is(<-errs, compute.ErrBusy) { + busy++ + } + } + if busy != 1 { + t.Errorf("got %d executions rejected as busy, want exactly 1", busy) + } +} + +// TestExecuteNoStateLeak checks that consecutive executions of the same module +// do not observe each other's state. +func TestExecuteNoStateLeak(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 2}) + module := loadModule(t, "echo") + + for _, input := range []string{"first", "second", "third"} { + res, err := s.Execute(context.Background(), compute.Request{Module: module, Input: []byte(input)}) + if err != nil { + t.Fatalf("execute %q: %v", input, err) + } + if res.Status != compute.StatusOK { + t.Fatalf("got status %v, want %v", res.Status, compute.StatusOK) + } + if string(res.Output) != input { + t.Errorf("got output %q, want %q", res.Output, input) + } + } +} + +func TestExecuteRequestMethod(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + method string + want string + }{ + {name: "post", method: "POST", want: "REQUEST_METHOD=POST"}, + {name: "get", method: "GET", want: "REQUEST_METHOD=GET"}, + {name: "custom", method: "PROPFIND", want: "REQUEST_METHOD=PROPFIND"}, + { + // No method means no environment at all, so the guest must not see + // a stray entry from the host. + name: "unset", + method: "", + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "method"), + Method: tc.method, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Status != compute.StatusOK { + t.Fatalf("got status %v, want %v (trap message: %q)", res.Status, compute.StatusOK, res.TrapMessage) + } + if string(res.Output) != tc.want { + t.Errorf("got output %q, want %q", res.Output, tc.want) + } + }) + } +} + +func TestStatusString(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + status compute.Status + want string + }{ + {compute.StatusOK, "ok"}, + {compute.StatusOutOfFuel, "out-of-fuel"}, + {compute.StatusTrap, "trap"}, + {compute.StatusInvalidModule, "invalid-module"}, + {compute.StatusHostError, "host-error"}, + {compute.Status(0), "unknown"}, + } { + if got := tc.status.String(); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + } +} diff --git a/pkg/compute/engine.go b/pkg/compute/engine.go new file mode 100644 index 00000000000..ef23e0563d0 --- /dev/null +++ b/pkg/compute/engine.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package compute runs untrusted WebAssembly modules downloaded from Swarm in a +// sandboxed execution engine and returns the deterministic result of the +// computation. +// +// This is the phase-0 skeleton: it executes modules in-process with wazero and +// does NOT yet enforce deterministic gas metering. It is intended to validate +// the API, download and wiring path end-to-end and must not be relied upon for +// reproducible-across-nodes output. A later phase replaces the engine with an +// out-of-process wasmtime worker that meters execution by deterministic fuel. +package compute + +import "context" + +// Status classifies the outcome of a WASM execution. +// +// StatusOK, StatusOutOfFuel, StatusTrap and StatusInvalidModule are program +// verdicts and are intended to be deterministic across nodes. StatusHostError +// signals an infrastructure failure local to this node (spawn failure, watchdog +// kill, IPC error) and must never be treated as a program result. +type Status uint8 + +const ( + // StatusOK indicates the module ran to completion and produced output. + StatusOK Status = iota + 1 + // StatusOutOfFuel indicates the module exceeded its deterministic gas budget. + StatusOutOfFuel + // StatusTrap indicates the module trapped (unreachable, out-of-bounds, non-zero exit, ...). + StatusTrap + // StatusInvalidModule indicates the bytes failed validation/compilation or import checks. + StatusInvalidModule + // StatusHostError indicates a non-deterministic infrastructure failure on this node. + StatusHostError +) + +// String returns a stable, lower-kebab representation used in responses and headers. +func (s Status) String() string { + switch s { + case StatusOK: + return "ok" + case StatusOutOfFuel: + return "out-of-fuel" + case StatusTrap: + return "trap" + case StatusInvalidModule: + return "invalid-module" + case StatusHostError: + return "host-error" + default: + return "unknown" + } +} + +// Result is the outcome of executing a module. +type Result struct { + Status Status + Output []byte + FuelConsumed uint64 + TrapMessage string +} + +// Request describes a single execution: the module to run, the caller-supplied +// input and the request metadata the module is allowed to observe. +// +// Every field is derived from the incoming HTTP request, never from the host, so +// the same Request produces the same Result on every node. +type Request struct { + // Module is the WASM binary to execute. + Module []byte + // Method is the HTTP method the endpoint was called with. It is exposed to + // the guest as the REQUEST_METHOD environment variable, following CGI + // convention. An empty value means no method is exposed. + Method string + // Input is the request body, handed to the guest on stdin. + Input []byte + // Limits bound the execution. + Limits Limits +} + +// Engine executes a single WASM module in isolation and returns its Result. +// +// A non-nil error is reserved for infrastructure failures (the engine could not +// run the module at all); program-level outcomes such as traps or invalid +// modules are reported through Result.Status with a nil error so callers can +// treat them as deterministic verdicts. +type Engine interface { + Execute(ctx context.Context, req Request) (Result, error) + Close() error +} diff --git a/pkg/compute/limits.go b/pkg/compute/limits.go new file mode 100644 index 00000000000..7ba604dcddc --- /dev/null +++ b/pkg/compute/limits.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute + +// Limits bound a single execution. They are supplied per request (clamped by the +// API layer to the operator-configured maxima) and passed to the engine. +type Limits struct { + // Fuel is the deterministic gas budget (instruction count). Zero means the + // engine default. Not enforced by the phase-0 wazero engine. + Fuel uint64 + // Memory is the maximum linear memory in bytes the module may allocate. + Memory uint64 + // Entrypoint is the exported function to invoke. Empty selects the module's + // WASI command entry (`_start`). + Entrypoint string +} + +const ( + // wasmPageSize is the size of a single WebAssembly memory page. + wasmPageSize = 65536 + // maxWasmPages is the maximum number of pages a 32-bit WASM memory can address. + maxWasmPages = 65536 +) + +// memoryPages converts a byte memory limit into a number of WASM pages, rounding +// up and clamping to the 32-bit maximum. A zero limit returns the maximum. +func (l Limits) memoryPages() uint32 { + if l.Memory == 0 { + return maxWasmPages + } + pages := (l.Memory + wasmPageSize - 1) / wasmPageSize + if pages > maxWasmPages { + return maxWasmPages + } + return uint32(pages) +} diff --git a/pkg/compute/testdata/README.md b/pkg/compute/testdata/README.md new file mode 100644 index 00000000000..e50760a42f2 --- /dev/null +++ b/pkg/compute/testdata/README.md @@ -0,0 +1,11 @@ +# compute test fixtures + +Each `*.wasm` module in this directory has its WebAssembly text source next to it +as `*.wat`. The `.wat` file is the source of record; regenerate a module after +editing it with: + + wat2wasm .wat -o .wasm + +The modules are deliberately tiny and hand-written so the sandbox behaviour they +exercise (output, traps, exits, rejected imports, memory limits, non-termination, +request metadata) stays obvious. diff --git a/pkg/compute/testdata/badimport.wasm b/pkg/compute/testdata/badimport.wasm new file mode 100644 index 0000000000000000000000000000000000000000..59566497e481c618243995e669482157418e4a8a GIT binary patch literal 62 zcmV~$!3uyN6h*;vRSXQGRljFJE?T6}dqkh#42RtUl(a+G6kC6nb2O`ccx~Th4l#(S OC|C32aa6JrEAtPDE(_lP literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/badimport.wat b/pkg/compute/testdata/badimport.wat new file mode 100644 index 00000000000..9e587a779b8 --- /dev/null +++ b/pkg/compute/testdata/badimport.wat @@ -0,0 +1,4 @@ +;; Imports a host function the sandbox does not provide. +(module + (import "env" "does_not_exist" (func $missing)) + (func (export "_start") (call $missing))) diff --git a/pkg/compute/testdata/bigmem.wasm b/pkg/compute/testdata/bigmem.wasm new file mode 100644 index 0000000000000000000000000000000000000000..56693561bed22e89bdc548c675fc81218302cd49 GIT binary patch literal 126 zcmWm7K@P$o07TIl(AtPGH6|{)^#mS)lTb`iSzsF=C9Zlsk0t)(tv_rE1i*tBHfxRR z0EjCu#U^jk+s^4VWiv;&cvo)iV>BiD>;Ngqtt7_XSv0|gDSiYRld_E&+;==eQ~!P0 Rp;zieXeiazoviNu`2(c$8sY!| literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/bigmem.wat b/pkg/compute/testdata/bigmem.wat new file mode 100644 index 00000000000..e43e1d22cc0 --- /dev/null +++ b/pkg/compute/testdata/bigmem.wat @@ -0,0 +1,9 @@ +;; Declares 100 pages (6.4 MiB) of linear memory so it can be rejected by a +;; lower memory limit. +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 100) + (data (i32.const 0) "\08\00\00\00\03\00\00\00big") + (func (export "_start") + (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 200))))) diff --git a/pkg/compute/testdata/echo.wasm b/pkg/compute/testdata/echo.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ee38ce012f7d8196d16dee8c05987985c35d8dd4 GIT binary patch literal 187 zcmZ{eu?oU47=-Wtt8K9z0z!xGqN|Iu1>YkLG-8L?k_3xWU(dmZ^3MZ!$8m6c!@&ec z0I24Evx;c#0lsmwm@w+_G;?@NQP1AKM!TGg`=Grw1MmkZ1z7wTEs!zMHX~56=iFI0 t`8OliAsQdSyCWUYB~$kyg|@4uPmBwvsg!(4QCn3F2~{a2H?NY^Nk4QDDY5_n literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/echo.wat b/pkg/compute/testdata/echo.wat new file mode 100644 index 00000000000..737c39a764d --- /dev/null +++ b/pkg/compute/testdata/echo.wat @@ -0,0 +1,15 @@ +;; Reads up to 64 bytes from stdin and writes them straight back to stdout. +;; The read iovec lives at address 0, the write iovec at address 8, and both +;; point at the 64-byte scratch buffer at address 16. +(module + (import "wasi_snapshot_preview1" "fd_read" + (func $fd_read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\10\00\00\00\40\00\00\00\10\00\00\00\00\00\00\00") + (func (export "_start") + (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 200))) + ;; copy the number of bytes read into the length field of the write iovec + (i32.store (i32.const 12) (i32.load (i32.const 200))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 204))))) diff --git a/pkg/compute/testdata/entrypoint.wasm b/pkg/compute/testdata/entrypoint.wasm new file mode 100644 index 0000000000000000000000000000000000000000..95af22edb8185c1781daf7aef347bbbe70fae503 GIT binary patch literal 137 zcmWm7OA5j;07cRF5HwA4^Bwp3AacIO7YQ9RYAJhSj{p zc>u(nr%`9Cvez-cLs2oAk2T|?c|FybY%zcoJA6a~QdFhRt|$_ZS+5u5~xA(28Md?I$P?j5{b1VE#9e%JxEaBg0rliO!G hQ+2t7Cx*5TxunB|H&&oiWN)dRR9`A+4K+4S7=Jt;64?L% literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/exit1.wat b/pkg/compute/testdata/exit1.wat new file mode 100644 index 00000000000..6709207345e --- /dev/null +++ b/pkg/compute/testdata/exit1.wat @@ -0,0 +1,4 @@ +;; Exits with a non-zero WASI exit code. +(module + (import "wasi_snapshot_preview1" "proc_exit" (func $proc_exit (param i32))) + (func (export "_start") (call $proc_exit (i32.const 1)))) diff --git a/pkg/compute/testdata/infloop.wasm b/pkg/compute/testdata/infloop.wasm new file mode 100644 index 0000000000000000000000000000000000000000..90e3dfde126e945e5e27b73e8cfdc059e73d0b35 GIT binary patch literal 41 vcmV~$(G36)5JbVZ2;mf^6BOXb=|5)Jegr6KK@6fa<>_zV2d%0QN5|{~Y-$A5 literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/infloop.wat b/pkg/compute/testdata/infloop.wat new file mode 100644 index 00000000000..27bec472e04 --- /dev/null +++ b/pkg/compute/testdata/infloop.wat @@ -0,0 +1,4 @@ +;; Loops forever; only an external interrupt stops it. +(module + (func (export "_start") + (loop $l (br $l)))) diff --git a/pkg/compute/testdata/method.wasm b/pkg/compute/testdata/method.wasm new file mode 100644 index 0000000000000000000000000000000000000000..22146e74f2c4b72a50a3db9aa7f409afd538d1df GIT binary patch literal 238 zcmaKizY4-I9E9(eR4vvaDmc^^aCLDJhYUVPS})a") to stdout, so tests can observe that request +;; metadata reaches the guest. +;; +;; environ_sizes_get stores the entry count at address 0 and the buffer size at +;; address 4; environ_get stores the pointer array at 64 and the NUL-terminated +;; strings at 128. The write iovec lives at address 8 and its length is the +;; buffer size minus the trailing NUL (zero when no environment is provided). +(module + (import "wasi_snapshot_preview1" "environ_sizes_get" + (func $environ_sizes_get (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "environ_get" + (func $environ_get (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "_start") + (drop (call $environ_sizes_get (i32.const 0) (i32.const 4))) + (drop (call $environ_get (i32.const 64) (i32.const 128))) + (i32.store (i32.const 8) (i32.const 128)) + (i32.store (i32.const 12) + (select + (i32.const 0) + (i32.sub (i32.load (i32.const 4)) (i32.const 1)) + (i32.eqz (i32.load (i32.const 4))))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 16))))) diff --git a/pkg/compute/testdata/trap.wasm b/pkg/compute/testdata/trap.wasm new file mode 100644 index 0000000000000000000000000000000000000000..e0de1700251d57267bda1ee6f5b4bd4120d22fab GIT binary patch literal 37 rcmV~$$qfJ?34YaNqL?MfLY( WkBgLNLO~8)-N|aQ^Bk_JnCO3U5FNt+ literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/writer.wat b/pkg/compute/testdata/writer.wat new file mode 100644 index 00000000000..16502b87636 --- /dev/null +++ b/pkg/compute/testdata/writer.wat @@ -0,0 +1,9 @@ +;; Writes "hello swarm" to stdout (fd 1) and returns from the WASI +;; command entrypoint `_start`. +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\08\00\00\00\0b\00\00\00hello swarm") + (func (export "_start") + (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 200))))) diff --git a/pkg/compute/wazero.go b/pkg/compute/wazero.go new file mode 100644 index 00000000000..aa5b4b1a189 --- /dev/null +++ b/pkg/compute/wazero.go @@ -0,0 +1,179 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + "github.com/tetratelabs/wazero/sys" +) + +const ( + // wasiModuleName is the only host module the sandbox instantiates and + // therefore the only one a guest may import from. + wasiModuleName = wasi_snapshot_preview1.ModuleName + // wasiEntrypoint is the entrypoint of a WASI command module. + wasiEntrypoint = "_start" + // envRequestMethod is the CGI-style environment variable carrying the HTTP + // method the execute endpoint was called with. + envRequestMethod = "REQUEST_METHOD" +) + +// wazeroEngine is the phase-0, in-process execution engine. +// +// WARNING: it does NOT meter execution deterministically and it wires WASI +// stdin/stdout for I/O, so its output is not guaranteed reproducible across +// nodes. It exists to exercise the download/API/wiring path and to be swapped +// out for the deterministic out-of-process wasmtime worker. +type wazeroEngine struct { + logger log.Logger +} + +func newWazeroEngine(logger log.Logger) *wazeroEngine { + return &wazeroEngine{logger: logger} +} + +// Execute compiles and runs the module, feeding the input on stdin and returning +// whatever the module writes to stdout as the result. A fresh runtime is created +// per call so no state leaks between executions. +func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) { + e.logger.Debug("execute: starting", "module_size", len(req.Module), "input_size", len(req.Input), "method", req.Method, "entrypoint", req.Limits.Entrypoint, "memory_limit", req.Limits.Memory) + + cfg := wazero.NewRuntimeConfig(). + // Interrupt execution when the context (watchdog) is cancelled. + WithCloseOnContextDone(true). + // Enforce the memory ceiling in-engine. + WithMemoryLimitPages(req.Limits.memoryPages()) + + r := wazero.NewRuntimeWithConfig(ctx, cfg) + defer r.Close(ctx) + + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { + // Failing to provide the host environment is an infrastructure fault. + return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + } + + compiled, err := r.CompileModule(ctx, req.Module) + if err != nil { + e.logger.Debug("execute: compile failed", "error", err) + return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil + } + e.logger.Debug("execute: compiled", "exports", compiled.ExportedFunctions(), "imported_memories", len(compiled.ImportedMemories())) + + // Reject anything the sandbox does not provide up front, so an unsatisfiable + // import is a deterministic verdict on the module rather than a link failure + // surfacing as a trap. + if err := checkImports(compiled); err != nil { + e.logger.Debug("execute: rejected import", "error", err) + return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil + } + + // Without an explicit entrypoint the module is run as a WASI command, which + // requires the conventional `_start` export. + if req.Limits.Entrypoint == "" { + if _, ok := compiled.ExportedFunctions()[wasiEntrypoint]; !ok { + e.logger.Debug("execute: missing entrypoint export", "want", wasiEntrypoint) + return Result{Status: StatusInvalidModule, TrapMessage: "module does not export " + wasiEntrypoint}, nil + } + } + + var stdout bytes.Buffer + modCfg := wazero.NewModuleConfig(). + WithName(""). + WithStdin(bytes.NewReader(req.Input)). + WithStdout(&stdout). + WithStderr(io.Discard) + + // Request metadata is exposed CGI-style. Only values derived from the request + // are passed; the host environment is never inherited, so the guest sees the + // same environment on every node. + if req.Method != "" { + modCfg = modCfg.WithEnv(envRequestMethod, req.Method) + } + + // With an explicit entrypoint, disable the automatic `_start` invocation and + // call the named export ourselves after instantiation. + if req.Limits.Entrypoint != "" { + modCfg = modCfg.WithStartFunctions() + } + + mod, err := r.InstantiateModule(ctx, compiled, modCfg) + if err != nil { + e.logger.Debug("execute: instantiate failed", "error", err) + if res, ok := classifyRunError(err, stdout.Bytes()); ok { + e.logger.Debug("execute: instantiate error classified", "status", res.Status) + return res, nil + } + return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + } + defer mod.Close(ctx) + + if req.Limits.Entrypoint != "" { + fn := mod.ExportedFunction(req.Limits.Entrypoint) + if fn == nil { + e.logger.Debug("execute: entrypoint not exported", "entrypoint", req.Limits.Entrypoint) + return Result{Status: StatusInvalidModule, TrapMessage: "entrypoint not exported: " + req.Limits.Entrypoint}, nil + } + if _, err := fn.Call(ctx); err != nil { + e.logger.Debug("execute: entrypoint call failed", "entrypoint", req.Limits.Entrypoint, "error", err) + if res, ok := classifyRunError(err, stdout.Bytes()); ok { + e.logger.Debug("execute: entrypoint error classified", "status", res.Status) + return res, nil + } + return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + } + } + + e.logger.Debug("execute: ok", "output_size", stdout.Len()) + return Result{Status: StatusOK, Output: stdout.Bytes()}, nil +} + +// checkImports verifies the module only imports from the host environment the +// sandbox instantiates. Importing memory is not supported at all. +func checkImports(compiled wazero.CompiledModule) error { + for _, f := range compiled.ImportedFunctions() { + moduleName, name, ok := f.Import() + if !ok { + continue + } + if moduleName != wasiModuleName { + return fmt.Errorf("unsupported import %q from module %q", name, moduleName) + } + } + if len(compiled.ImportedMemories()) > 0 { + return errors.New("importing memory is not supported") + } + return nil +} + +// classifyRunError maps a wazero execution error to a program verdict. The bool +// result is false when the error is not a program-level failure (i.e. it should +// be surfaced as an infrastructure error). +func classifyRunError(err error, out []byte) (Result, bool) { + var exitErr *sys.ExitError + if errors.As(err, &exitErr) { + switch exitErr.ExitCode() { + case 0: + // Normal WASI exit. + return Result{Status: StatusOK, Output: out}, true + case sys.ExitCodeContextCanceled, sys.ExitCodeDeadlineExceeded: + // The watchdog or the caller's context stopped the module. This is a + // local, non-deterministic kill, not a verdict on the program. + return Result{}, false + } + return Result{Status: StatusTrap, Output: out, TrapMessage: err.Error()}, true + } + // Any other execution error is a trap (unreachable, OOB access, ...). + return Result{Status: StatusTrap, Output: out, TrapMessage: err.Error()}, true +} + +func (e *wazeroEngine) Close() error { return nil } diff --git a/pkg/node/node.go b/pkg/node/node.go index b085278969a..01bb3860f97 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -30,6 +30,7 @@ import ( "github.com/ethersphere/bee/v2/pkg/accounting" "github.com/ethersphere/bee/v2/pkg/addressbook" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" "github.com/ethersphere/bee/v2/pkg/config" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds/factory" @@ -91,6 +92,10 @@ import ( // LoggerName is the tree path name of the logger for this package. const LoggerName = "node" +// maxWasmWorkers caps the automatically chosen number of concurrent WASM +// executions so a many-core node does not devote all of it to untrusted code. +const maxWasmWorkers = 8 + type Bee struct { logger log.Logger p2pService io.Closer @@ -126,6 +131,7 @@ type Bee struct { shutdownMutex sync.Mutex syncingStopped *syncutil.Signaler accesscontrolCloser io.Closer + computeCloser io.Closer ethClientCloser func() } @@ -198,6 +204,14 @@ type Options struct { WarmupTime time.Duration WelcomeMessage string WhitelistedWithdrawalAddress []string + WasmExecuteEnable bool + WasmWorkers int + WasmExecTimeout time.Duration + WasmMaxModuleSize uint64 + WasmFuel uint64 + WasmMaxFuel uint64 + WasmMemory uint64 + WasmMaxMemory uint64 } const ( @@ -1332,6 +1346,24 @@ func NewBee( feedFactory := factory.New(localStore.Download(true)) steward := steward.New(localStore, retrieval, localStore.Cache()) + var computeService compute.Engine + if o.WasmExecuteEnable { + workers := o.WasmWorkers + if workers < 1 { + workers = min(runtime.NumCPU(), maxWasmWorkers) + } + cs, err := compute.New(compute.Options{ + Workers: workers, + Watchdog: o.WasmExecTimeout, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("compute service: %w", err) + } + computeService = cs + b.computeCloser = cs + } + extraOpts := api.ExtraOptions{ Pingpong: pingPong, TopologyDriver: kad, @@ -1354,6 +1386,14 @@ func NewBee( SyncStatus: syncStatusFn, NodeStatus: nodeStatus, PinIntegrity: localStore.PinIntegrity(), + Compute: computeService, + ExecuteConfig: api.ExecuteConfig{ + MaxModuleSize: o.WasmMaxModuleSize, + DefaultFuel: o.WasmFuel, + MaxFuel: o.WasmMaxFuel, + DefaultMemory: o.WasmMemory, + MaxMemory: o.WasmMaxMemory, + }, } if o.APIAddr != "" { @@ -1543,6 +1583,7 @@ func (b *Bee) Shutdown() error { b.ethClientCloser() } + tryClose(b.computeCloser, "compute") tryClose(b.accesscontrolCloser, "accesscontrol") tryClose(b.tracerCloser, "tracer") tryClose(b.topologyCloser, "topology driver") From c4cba9327f38bd9b14f496d893490fa9adbb8922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Fri, 28 Aug 2026 09:01:58 +0200 Subject: [PATCH 2/3] feat: wasm host functions --- cmd/bee/cmd/cmd.go | 16 +- cmd/bee/cmd/start.go | 8 +- openapi/Swarm.yaml | 33 +- openapi/SwarmCommon.yaml | 64 +-- pkg/api/api.go | 14 +- pkg/api/execute.go | 65 ++- pkg/api/execute_test.go | 124 +++--- pkg/api/host.go | 192 +++++++++ pkg/api/host_test.go | 302 +++++++++++++ pkg/api/status.go | 2 + pkg/api/status_test.go | 112 ++--- pkg/compute/README.md | 142 ++++++ pkg/compute/compute.go | 2 +- pkg/compute/compute_test.go | 1 - pkg/compute/engine.go | 48 ++- pkg/compute/export_test.go | 31 ++ pkg/compute/host.go | 373 ++++++++++++++++ pkg/compute/host_test.go | 570 +++++++++++++++++++++++++ pkg/compute/limits.go | 52 ++- pkg/compute/testdata/README.md | 33 +- pkg/compute/testdata/hostbadptr.wasm | Bin 0 -> 196 bytes pkg/compute/testdata/hostbadptr.wat | 15 + pkg/compute/testdata/hostbytesget.wasm | Bin 0 -> 286 bytes pkg/compute/testdata/hostbytesget.wat | 28 ++ pkg/compute/testdata/hostbytesput.wasm | Bin 0 -> 288 bytes pkg/compute/testdata/hostbytesput.wat | 25 ++ pkg/compute/testdata/hostcalls.wasm | Bin 0 -> 323 bytes pkg/compute/testdata/hostcalls.wat | 30 ++ pkg/compute/testdata/hostchunk.wasm | Bin 0 -> 352 bytes pkg/compute/testdata/hostchunk.wat | 32 ++ pkg/compute/testdata/hostnested.wasm | Bin 0 -> 302 bytes pkg/compute/testdata/hostnested.wat | 26 ++ pkg/compute/testdata/hostputtrap.wasm | Bin 0 -> 289 bytes pkg/compute/testdata/hostputtrap.wat | 25 ++ pkg/compute/testdata/hostunknown.wasm | Bin 0 -> 98 bytes pkg/compute/testdata/hostunknown.wat | 7 + pkg/compute/wazero.go | 89 +++- pkg/node/node.go | 17 +- 38 files changed, 2277 insertions(+), 201 deletions(-) create mode 100644 pkg/api/host.go create mode 100644 pkg/api/host_test.go create mode 100644 pkg/compute/README.md create mode 100644 pkg/compute/export_test.go create mode 100644 pkg/compute/host.go create mode 100644 pkg/compute/host_test.go create mode 100644 pkg/compute/testdata/hostbadptr.wasm create mode 100644 pkg/compute/testdata/hostbadptr.wat create mode 100644 pkg/compute/testdata/hostbytesget.wasm create mode 100644 pkg/compute/testdata/hostbytesget.wat create mode 100644 pkg/compute/testdata/hostbytesput.wasm create mode 100644 pkg/compute/testdata/hostbytesput.wat create mode 100644 pkg/compute/testdata/hostcalls.wasm create mode 100644 pkg/compute/testdata/hostcalls.wat create mode 100644 pkg/compute/testdata/hostchunk.wasm create mode 100644 pkg/compute/testdata/hostchunk.wat create mode 100644 pkg/compute/testdata/hostnested.wasm create mode 100644 pkg/compute/testdata/hostnested.wat create mode 100644 pkg/compute/testdata/hostputtrap.wasm create mode 100644 pkg/compute/testdata/hostputtrap.wat create mode 100644 pkg/compute/testdata/hostunknown.wasm create mode 100644 pkg/compute/testdata/hostunknown.wat diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index b83f7d41072..98bf0d3c398 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -90,10 +90,14 @@ const ( optionNameWasmWorkers = "wasm-workers" optionNameWasmExecTimeout = "wasm-exec-timeout" optionNameWasmMaxModuleSize = "wasm-max-module-size" - optionNameWasmFuel = "wasm-fuel" - optionNameWasmMaxFuel = "wasm-max-fuel" optionNameWasmMemory = "wasm-memory" optionNameWasmMaxMemory = "wasm-max-memory" + optionNameWasmHostCalls = "wasm-host-calls" + optionNameWasmMaxHostCalls = "wasm-max-host-calls" + optionNameWasmHostBytes = "wasm-host-bytes" + optionNameWasmMaxHostBytes = "wasm-max-host-bytes" + optionNameWasmExecDepth = "wasm-exec-depth" + optionNameWasmMaxExecDepth = "wasm-max-exec-depth" optionP2PWSSAddr = "p2p-wss-addr" optionNATWSSAddr = "nat-wss-addr" optionAutoTLSDomain = "autotls-domain" @@ -348,10 +352,14 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().Int(optionNameWasmWorkers, 0, "maximum number of concurrent WASM executions, 0 means min(number of CPUs, 8)") cmd.Flags().Duration(optionNameWasmExecTimeout, 10*time.Second, "wall-clock watchdog timeout for a single WASM execution") cmd.Flags().Uint64(optionNameWasmMaxModuleSize, 16*1024*1024, "maximum size in bytes of a WASM module that may be executed") - cmd.Flags().Uint64(optionNameWasmFuel, 100_000_000, "default fuel (gas) budget for a single WASM execution") - cmd.Flags().Uint64(optionNameWasmMaxFuel, 1_000_000_000, "maximum fuel (gas) budget a request may ask for") cmd.Flags().Uint64(optionNameWasmMemory, 32*1024*1024, "default linear memory limit in bytes for a single WASM execution") cmd.Flags().Uint64(optionNameWasmMaxMemory, 256*1024*1024, "maximum linear memory limit in bytes a request may ask for") + cmd.Flags().Uint64(optionNameWasmHostCalls, 64, "default number of swarm host calls a single WASM execution may make") + cmd.Flags().Uint64(optionNameWasmMaxHostCalls, 1024, "maximum number of swarm host calls a request may ask for") + cmd.Flags().Uint64(optionNameWasmHostBytes, 32*1024*1024, "default total bytes swarm host calls of a single WASM execution may move") + cmd.Flags().Uint64(optionNameWasmMaxHostBytes, 256*1024*1024, "maximum total bytes moved by swarm host calls a request may ask for") + cmd.Flags().Uint64(optionNameWasmExecDepth, 4, "default maximum nesting depth of swarm_execute calls") + cmd.Flags().Uint64(optionNameWasmMaxExecDepth, 8, "maximum nesting depth of swarm_execute calls a request may ask for") cmd.Flags().String(optionP2PWSSAddr, ":1635", "p2p wss address") cmd.Flags().String(optionNATWSSAddr, "", "WSS NAT exposed address") cmd.Flags().String(optionAutoTLSDomain, p2pforge.DefaultForgeDomain, "autotls domain") diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index 7ca192ec5ed..2dd1e2d82e7 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -367,10 +367,14 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo WasmWorkers: c.config.GetInt(optionNameWasmWorkers), WasmExecTimeout: c.config.GetDuration(optionNameWasmExecTimeout), WasmMaxModuleSize: c.config.GetUint64(optionNameWasmMaxModuleSize), - WasmFuel: c.config.GetUint64(optionNameWasmFuel), - WasmMaxFuel: c.config.GetUint64(optionNameWasmMaxFuel), WasmMemory: c.config.GetUint64(optionNameWasmMemory), WasmMaxMemory: c.config.GetUint64(optionNameWasmMaxMemory), + WasmHostCalls: c.config.GetUint64(optionNameWasmHostCalls), + WasmMaxHostCalls: c.config.GetUint64(optionNameWasmMaxHostCalls), + WasmHostBytes: c.config.GetUint64(optionNameWasmHostBytes), + WasmMaxHostBytes: c.config.GetUint64(optionNameWasmMaxHostBytes), + WasmExecDepth: c.config.GetUint64(optionNameWasmExecDepth), + WasmMaxExecDepth: c.config.GetUint64(optionNameWasmMaxExecDepth), }) return b, err diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index e7ef9e164f4..d86becc2778 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -219,9 +219,11 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmReference" required: true description: Swarm address reference of the WASM module - - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmFuelLimit" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmMemoryLimit" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmEntrypoint" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostCallsLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostBytesLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmDepthLimit" get: &executeOperation summary: "Execute a WASM module stored in Swarm" description: > @@ -247,6 +249,33 @@ paths: A verdict on the program itself (`trap`, `invalid-module`) is reported as 400 with the verdict in the `swarm-wasm-status` header; a failure local to this node is reported as 500. + + + **Node access.** Besides `wasi_snapshot_preview1`, a module may import a + host module named `swarm` to read and write Swarm data: + `swarm_bytes_get`, `swarm_bytes_put`, `swarm_chunk_get`, + `swarm_chunk_put` and `swarm_execute`. The full ABI, including the + result codes and the caller-provides-buffer convention, is documented in + `pkg/compute/README.md`. Importing a name that module does not define is + rejected before the module runs. + + + Uploads are paid for by the postage batch the module passes to a put + call, which it can only have received as input; the node resolves it + exactly as `POST /chunks` does, so this path grants no authority the + HTTP API does not already grant. One execution gets one upload session + and therefore one batch. Uploads are **deferred**: when the response + returns, the data is in the local upload store but not yet acknowledged + by the network, and the session is committed only if the execution + succeeded — a module that traps leaves nothing behind. + + + **Experimental.** Output is not reproducible across nodes: a module + reaching the node observes state local to it, and this engine enforces + no deterministic gas budget. What a module may make the node do is + bounded by the host-call, host-byte and depth budgets instead. + The endpoint runs untrusted code in the node's own process and + should not be enabled on a public gateway. tags: - Execute requestBody: @@ -263,8 +292,6 @@ paths: headers: "swarm-wasm-status": $ref: "SwarmCommon.yaml#/components/headers/SwarmWasmStatus" - "swarm-wasm-fuel-consumed": - $ref: "SwarmCommon.yaml#/components/headers/SwarmWasmFuelConsumed" content: application/octet-stream: schema: diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index 44f49162048..ce34674ad4f 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -659,12 +659,11 @@ components: WasmStatus: type: string description: > - Outcome of a WASM execution. `ok`, `out-of-fuel`, `trap` and - `invalid-module` are verdicts on the program itself; `host-error` is a - failure local to the node and says nothing about the program. + Outcome of a WASM execution. `ok`, `trap` and `invalid-module` are + verdicts on the program itself; `host-error` is a failure local to the + node and says nothing about the program. enum: - ok - - out-of-fuel - trap - invalid-module - host-error @@ -678,9 +677,6 @@ components: type: string format: byte description: Bytes the module wrote to its output - fuelConsumed: - type: integer - format: int64 trapMessage: type: string description: Explanation of a non-`ok` status @@ -988,6 +984,8 @@ components: type: integer isWarmingUp: type: boolean + isWasmEnabled: + type: boolean StatusPeersResponse: type: object @@ -1115,12 +1113,6 @@ components: schema: $ref: "#/components/schemas/WasmStatus" - SwarmWasmFuelConsumed: - description: "Amount of fuel (gas) the execution consumed" - schema: - type: integer - format: int64 - SwarmFeedIndex: description: "The index of the found update" schema: @@ -1328,17 +1320,6 @@ components: description: > Indicates whether the uploaded data should be sent to the network immediately or deferred. Default: deferred (true) - SwarmWasmFuelLimit: - in: header - name: swarm-wasm-fuel-limit - schema: - type: integer - format: int64 - required: false - description: > - Fuel (gas) budget for the execution. Values above the limit configured - on the node are clamped to it. Defaults to the node configured value. - SwarmWasmMemoryLimit: in: header name: swarm-wasm-memory-limit @@ -1359,7 +1340,40 @@ components: required: false description: > Exported function to invoke. Defaults to the WASI command entrypoint - `_start`. + `_start`. Applies to the outermost module only: a module run through + `swarm_execute` is always started as a WASI command. + + SwarmWasmHostCallsLimit: + in: header + name: swarm-wasm-host-calls-limit + schema: + type: integer + required: false + description: > + Maximum number of `swarm` host calls the execution may make, shared + across nested executions. May only lower the node's configured value. + + SwarmWasmHostBytesLimit: + in: header + name: swarm-wasm-host-bytes-limit + schema: + type: integer + required: false + description: > + Maximum total payload, in bytes, that `swarm` host calls may move in + either direction, shared across nested executions. May only lower the + node's configured value. + + SwarmWasmDepthLimit: + in: header + name: swarm-wasm-depth-limit + schema: + type: integer + required: false + description: > + Maximum number of execution levels a `swarm_execute` call tree may + reach, the outermost execution included, so `1` permits no nesting at + all. May only lower the node's configured value. SwarmCache: in: header diff --git a/pkg/api/api.go b/pkg/api/api.go index 6686258eb80..2fc5d449c13 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -95,11 +95,12 @@ const ( SwarmActPublisherHeader = "Swarm-Act-Publisher" SwarmActHistoryAddressHeader = "Swarm-Act-History-Address" - SwarmWasmFuelLimitHeader = "Swarm-Wasm-Fuel-Limit" - SwarmWasmMemoryLimitHeader = "Swarm-Wasm-Memory-Limit" - SwarmWasmEntrypointHeader = "Swarm-Wasm-Entrypoint" - SwarmWasmStatusHeader = "Swarm-Wasm-Status" - SwarmWasmFuelConsumedHeader = "Swarm-Wasm-Fuel-Consumed" + SwarmWasmMemoryLimitHeader = "Swarm-Wasm-Memory-Limit" + SwarmWasmEntrypointHeader = "Swarm-Wasm-Entrypoint" + SwarmWasmHostCallsHeader = "Swarm-Wasm-Host-Calls-Limit" + SwarmWasmHostBytesHeader = "Swarm-Wasm-Host-Bytes-Limit" + SwarmWasmDepthHeader = "Swarm-Wasm-Depth-Limit" + SwarmWasmStatusHeader = "Swarm-Wasm-Status" ImmutableHeader = "Immutable" GasPriceHeader = "Gas-Price" @@ -598,7 +599,8 @@ func (s *Service) corsHandler(h http.Handler) http.Handler { SwarmPostageBatchIdHeader, SwarmPostageStampHeader, SwarmDeferredUploadHeader, SwarmRedundancyLevelHeader, SwarmRedundancyStrategyHeader, SwarmRedundancyFallbackModeHeader, SwarmChunkRetrievalTimeoutHeader, SwarmLookAheadBufferSizeHeader, SwarmFeedIndexHeader, SwarmFeedIndexNextHeader, SwarmSocSignatureHeader, SwarmOnlyRootChunk, GasPriceHeader, GasLimitHeader, ImmutableHeader, - SwarmActHeader, SwarmActTimestampHeader, SwarmActPublisherHeader, SwarmActHistoryAddressHeader, + SwarmActHeader, SwarmActTimestampHeader, SwarmActPublisherHeader, SwarmActHistoryAddressHeader, SwarmWasmMemoryLimitHeader, SwarmWasmEntrypointHeader, + SwarmWasmHostCallsHeader, SwarmWasmHostBytesHeader, SwarmWasmDepthHeader, SwarmWasmStatusHeader, } allowedHeadersStr := strings.Join(allowedHeaders, ", ") diff --git a/pkg/api/execute.go b/pkg/api/execute.go index ae21d7efb45..939fa2d8e26 100644 --- a/pkg/api/execute.go +++ b/pkg/api/execute.go @@ -8,7 +8,6 @@ import ( "errors" "io" "net/http" - "strconv" "strings" "github.com/ethersphere/bee/v2/pkg/compute" @@ -26,18 +25,24 @@ import ( // Per-request headers may only lower a limit below its configured maximum. type ExecuteConfig struct { MaxModuleSize uint64 - DefaultFuel uint64 - MaxFuel uint64 DefaultMemory uint64 MaxMemory uint64 + // Bounds on what a module may make the node do through the swarm host + // module. wazero has no gas metering, so these are what stop a module + // fetching or storing without end. + DefaultHostCalls uint64 + MaxHostCalls uint64 + DefaultHostBytes uint64 + MaxHostBytes uint64 + DefaultDepth uint64 + MaxDepth uint64 } // executeResponse is the structured (JSON) representation of an execution result. type executeResponse struct { - Status string `json:"status"` - Output []byte `json:"output"` - FuelConsumed uint64 `json:"fuelConsumed"` - TrapMessage string `json:"trapMessage,omitempty"` + Status string `json:"status"` + Output []byte `json:"output"` + TrapMessage string `json:"trapMessage,omitempty"` } // executeHandler downloads the WASM module addressed by {address}, runs it in the @@ -66,9 +71,11 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { } headers := struct { - Fuel *uint64 `map:"Swarm-Wasm-Fuel-Limit"` Memory *uint64 `map:"Swarm-Wasm-Memory-Limit"` Entrypoint string `map:"Swarm-Wasm-Entrypoint"` + HostCalls *uint64 `map:"Swarm-Wasm-Host-Calls-Limit"` + HostBytes *uint64 `map:"Swarm-Wasm-Host-Bytes-Limit"` + Depth *uint64 `map:"Swarm-Wasm-Depth-Limit"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -84,9 +91,11 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { } lim := compute.Limits{ - Fuel: clampLimit(headers.Fuel, s.executeConfig.DefaultFuel, s.executeConfig.MaxFuel), - Memory: clampLimit(headers.Memory, s.executeConfig.DefaultMemory, s.executeConfig.MaxMemory), - Entrypoint: headers.Entrypoint, + Memory: clampLimit(headers.Memory, s.executeConfig.DefaultMemory, s.executeConfig.MaxMemory), + Entrypoint: headers.Entrypoint, + MaxHostCalls: uint32(clampLimit(headers.HostCalls, s.executeConfig.DefaultHostCalls, s.executeConfig.MaxHostCalls)), + MaxHostBytes: clampLimit(headers.HostBytes, s.executeConfig.DefaultHostBytes, s.executeConfig.MaxHostBytes), + MaxDepth: uint32(clampLimit(headers.Depth, s.executeConfig.DefaultDepth, s.executeConfig.MaxDepth)), } // Download and reassemble the module bytes, capped at the configured maximum. @@ -128,13 +137,21 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { return } + // The host is per-request: any upload it opens belongs to this execution + // alone and is committed or dropped below. + host := s.newExecuteHost(logger, lim.HostBytes()) + result, err := s.compute.Execute(r.Context(), compute.Request{ Module: module, Method: r.Method, Input: input, Limits: lim, + Host: host, }) if err != nil { + if cerr := host.Close(false); cerr != nil { + logger.Debug("execute: discarding upload session failed", "error", cerr) + } if errors.Is(err, compute.ErrBusy) { jsonhttp.TooManyRequests(w, "execution workers busy") return @@ -145,6 +162,15 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { return } + // Only a clean run commits its uploads; a trapped or rejected module leaves + // nothing behind. + if err := host.Close(result.Status == compute.StatusOK); err != nil { + logger.Debug("execute: closing upload session failed", "error", err) + logger.Error(nil, "execute: closing upload session failed") + jsonhttp.InternalServerError(w, "could not store uploaded data") + return + } + renderExecResult(w, format, result) } @@ -153,7 +179,6 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { // chosen format. func renderExecResult(w http.ResponseWriter, format string, res compute.Result) { w.Header().Set(SwarmWasmStatusHeader, res.Status.String()) - w.Header().Set(SwarmWasmFuelConsumedHeader, strconv.FormatUint(res.FuelConsumed, 10)) switch res.Status { case compute.StatusInvalidModule, compute.StatusTrap: @@ -165,14 +190,13 @@ func renderExecResult(w http.ResponseWriter, format string, res compute.Result) return } - // StatusOK or StatusOutOfFuel: 200. For out-of-fuel there may be no output. + // StatusOK: 200. switch format { case formatJSON: jsonhttp.OK(w, executeResponse{ - Status: res.Status.String(), - Output: res.Output, - FuelConsumed: res.FuelConsumed, - TrapMessage: res.TrapMessage, + Status: res.Status.String(), + Output: res.Output, + TrapMessage: res.TrapMessage, }) case formatHTML: w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8") @@ -190,10 +214,9 @@ func renderExecResult(w http.ResponseWriter, format string, res compute.Result) func execErrorBody(format string, res compute.Result) interface{} { if format == formatJSON { return executeResponse{ - Status: res.Status.String(), - Output: res.Output, - FuelConsumed: res.FuelConsumed, - TrapMessage: res.TrapMessage, + Status: res.Status.String(), + Output: res.Output, + TrapMessage: res.TrapMessage, } } msg := res.Status.String() diff --git a/pkg/api/execute_test.go b/pkg/api/execute_test.go index fdcdb018016..380f0a3e98b 100644 --- a/pkg/api/execute_test.go +++ b/pkg/api/execute_test.go @@ -73,9 +73,8 @@ func TestExecute(t *testing.T) { module := []byte("this stands in for a wasm module") engine := &mockEngine{result: compute.Result{ - Status: compute.StatusOK, - Output: []byte("computed output"), - FuelConsumed: 4711, + Status: compute.StatusOK, + Output: []byte("computed output"), }} client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) @@ -86,7 +85,6 @@ func TestExecute(t *testing.T) { jsonhttptest.WithRequestBody(bytes.NewReader([]byte("the input"))), jsonhttptest.WithExpectedResponse([]byte("computed output")), jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), - jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmFuelConsumedHeader, "4711"), ) if engine.calls != 1 { @@ -197,12 +195,6 @@ func TestExecuteStatusMapping(t *testing.T) { wantHTTP: http.StatusOK, wantStatus: "ok", }, - { - name: "out of fuel", - result: compute.Result{Status: compute.StatusOutOfFuel}, - wantHTTP: http.StatusOK, - wantStatus: "out-of-fuel", - }, { name: "trap", result: compute.Result{Status: compute.StatusTrap, TrapMessage: "unreachable"}, @@ -306,9 +298,8 @@ func TestExecuteContentNegotiation(t *testing.T) { t.Parallel() engine := &mockEngine{result: compute.Result{ - Status: compute.StatusOK, - Output: output, - FuelConsumed: 42, + Status: compute.StatusOK, + Output: output, }} client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) addr := uploadModule(t, client, []byte("module")) @@ -332,9 +323,8 @@ func TestExecuteContentNegotiation(t *testing.T) { if tc.wantJSON { var resp struct { - Status string `json:"status"` - Output []byte `json:"output"` - FuelConsumed uint64 `json:"fuelConsumed"` + Status string `json:"status"` + Output []byte `json:"output"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("unmarshal response %q: %v", body, err) @@ -345,9 +335,6 @@ func TestExecuteContentNegotiation(t *testing.T) { if !bytes.Equal(resp.Output, output) { t.Errorf("got output %q, want %q", resp.Output, output) } - if resp.FuelConsumed != 42 { - t.Errorf("got fuel consumed %d, want 42", resp.FuelConsumed) - } } if got := engine.calls > 0; got != tc.wantExecute { @@ -363,39 +350,82 @@ func TestExecuteLimits(t *testing.T) { t.Parallel() cfg := api.ExecuteConfig{ - DefaultFuel: 1000, - MaxFuel: 5000, - DefaultMemory: 2048, - MaxMemory: 8192, + DefaultMemory: 2048, + MaxMemory: 8192, + DefaultHostCalls: 16, + MaxHostCalls: 64, + DefaultHostBytes: 1024, + MaxHostBytes: 4096, + DefaultDepth: 2, + MaxDepth: 4, + } + + // defaults are the limits a request without any override resolves to. + defaults := compute.Limits{ + Memory: 2048, + MaxHostCalls: 16, + MaxHostBytes: 1024, + MaxDepth: 2, + } + with := func(f func(*compute.Limits)) compute.Limits { + l := defaults + f(&l) + return l } for _, tc := range []struct { - name string - fuel string - memory string - entrypoint string - want compute.Limits + name string + headers map[string]string + want compute.Limits }{ { name: "defaults", - want: compute.Limits{Fuel: 1000, Memory: 2048}, + want: defaults, }, { - name: "request below the maximum is honored", - fuel: "200", - memory: "1024", - want: compute.Limits{Fuel: 200, Memory: 1024}, + name: "request below the maximum is honored", + headers: map[string]string{api.SwarmWasmMemoryLimitHeader: "1024"}, + want: with(func(l *compute.Limits) { l.Memory = 1024 }), }, { - name: "request above the maximum is clamped", - fuel: "999999", - memory: "999999", - want: compute.Limits{Fuel: 5000, Memory: 8192}, + name: "request above the maximum is clamped", + headers: map[string]string{api.SwarmWasmMemoryLimitHeader: "999999"}, + want: with(func(l *compute.Limits) { l.Memory = 8192 }), }, { - name: "entrypoint is passed through", - entrypoint: "run", - want: compute.Limits{Fuel: 1000, Memory: 2048, Entrypoint: "run"}, + name: "entrypoint is passed through", + headers: map[string]string{api.SwarmWasmEntrypointHeader: "run"}, + want: with(func(l *compute.Limits) { l.Entrypoint = "run" }), + }, + { + name: "host calls below the maximum are honored", + headers: map[string]string{api.SwarmWasmHostCallsHeader: "32"}, + want: with(func(l *compute.Limits) { l.MaxHostCalls = 32 }), + }, + { + name: "host calls above the maximum are clamped", + headers: map[string]string{api.SwarmWasmHostCallsHeader: "999999"}, + want: with(func(l *compute.Limits) { l.MaxHostCalls = 64 }), + }, + { + name: "host bytes below the maximum are honored", + headers: map[string]string{api.SwarmWasmHostBytesHeader: "2048"}, + want: with(func(l *compute.Limits) { l.MaxHostBytes = 2048 }), + }, + { + name: "host bytes above the maximum are clamped", + headers: map[string]string{api.SwarmWasmHostBytesHeader: "999999"}, + want: with(func(l *compute.Limits) { l.MaxHostBytes = 4096 }), + }, + { + name: "depth below the maximum is honored", + headers: map[string]string{api.SwarmWasmDepthHeader: "1"}, + want: with(func(l *compute.Limits) { l.MaxDepth = 1 }), + }, + { + name: "depth above the maximum is clamped", + headers: map[string]string{api.SwarmWasmDepthHeader: "999999"}, + want: with(func(l *compute.Limits) { l.MaxDepth = 4 }), }, } { t.Run(tc.name, func(t *testing.T) { @@ -409,14 +439,8 @@ func TestExecuteLimits(t *testing.T) { jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), jsonhttptest.WithNoResponseBody(), } - if tc.fuel != "" { - opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmFuelLimitHeader, tc.fuel)) - } - if tc.memory != "" { - opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmMemoryLimitHeader, tc.memory)) - } - if tc.entrypoint != "" { - opts = append(opts, jsonhttptest.WithRequestHeader(api.SwarmWasmEntrypointHeader, tc.entrypoint)) + for name, value := range tc.headers { + opts = append(opts, jsonhttptest.WithRequestHeader(name, value)) } jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, opts...) @@ -447,9 +471,9 @@ func TestExecuteInvalidRequest(t *testing.T) { ) }) - t.Run("invalid fuel limit", func(t *testing.T) { + t.Run("invalid memory limit", func(t *testing.T) { jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusBadRequest, - jsonhttptest.WithRequestHeader(api.SwarmWasmFuelLimitHeader, "not a number"), + jsonhttptest.WithRequestHeader(api.SwarmWasmMemoryLimitHeader, "not a number"), ) }) diff --git a/pkg/api/host.go b/pkg/api/host.go new file mode 100644 index 00000000000..d54cb4ece6d --- /dev/null +++ b/pkg/api/host.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "bytes" + "context" + "errors" + "sync" + + "github.com/ethersphere/bee/v2/pkg/cac" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/file/joiner" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/postage" + "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storer" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/ethersphere/bee/v2/pkg/topology" +) + +// executeHost serves the swarm host calls of a single execution. It is built +// per request in executeHandler and closed after the run, so the upload session +// it opens spans one execution and no more. +type executeHost struct { + s *Service + logger log.Logger + cache bool + maxBytes uint64 + + // mu guards the lazily opened session. A guest is single-threaded and + // nested executions are sequential, but the session outlives individual + // calls and is cheap to guard. + mu sync.Mutex + batchID []byte + session storer.PutterSession +} + +var _ compute.Host = (*executeHost)(nil) + +// newExecuteHost builds the per-request host. maxBytes is the execution's byte +// budget, used to refuse an oversized download before it is materialised. +func (s *Service) newExecuteHost(logger log.Logger, maxBytes uint64) *executeHost { + return &executeHost{s: s, logger: logger, cache: true, maxBytes: maxBytes} +} + +// BytesGet reassembles data of arbitrary length, as GET /bytes does. +func (h *executeHost) BytesGet(ctx context.Context, addr swarm.Address) ([]byte, error) { + reader, l, err := joiner.New(ctx, h.s.storer.Download(h.cache), h.s.storer.Cache(), addr, redundancy.DefaultDownloadLevel) + if err != nil { + return nil, mapHostErr(err) + } + // The span is known up front, so an oversized object is refused without + // reading it. readCapped still bounds the read for a lying or absent span. + if h.maxBytes > 0 && l >= 0 && uint64(l) > h.maxBytes { + return nil, compute.ErrTooLarge + } + data, err := readCapped(reader, h.maxBytes) + if err != nil { + if errors.Is(err, errTooLarge) { + return nil, compute.ErrTooLarge + } + return nil, mapHostErr(err) + } + return data, nil +} + +// BytesPut splits data of arbitrary length through the same pipeline POST +// /bytes uses and returns the root reference. +// +// Encryption and redundancy are deliberately not exposed to the guest: an +// encrypted reference is 64 bytes and the guest ABI writes a fixed 32. +func (h *executeHost) BytesPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) { + putter, err := h.putter(ctx, batchID) + if err != nil { + return swarm.ZeroAddress, err + } + addr, err := requestPipelineFn(putter, false, redundancy.DefaultUploadLevel)(ctx, bytes.NewReader(data)) + if err != nil { + return swarm.ZeroAddress, mapHostErr(err) + } + return addr, nil +} + +// ChunkGet retrieves a single chunk, as GET /chunks/{addr} does. +func (h *executeHost) ChunkGet(ctx context.Context, addr swarm.Address) ([]byte, error) { + chunk, err := h.s.storer.Download(h.cache).Get(ctx, addr) + if err != nil { + return nil, mapHostErr(err) + } + return chunk.Data(), nil +} + +// ChunkPut stores a single content-addressed chunk verbatim. Unlike POST +// /chunks there is no single owner chunk path: a SOC needs a signature the +// guest has no way to produce. +func (h *executeHost) ChunkPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) { + putter, err := h.putter(ctx, batchID) + if err != nil { + return swarm.ZeroAddress, err + } + chunk, err := cac.NewWithDataSpan(data) + if err != nil { + // Malformed chunk bytes are the guest's mistake, not the node's. + h.logger.Debug("execute host: invalid chunk data", "error", err) + return swarm.ZeroAddress, compute.ErrInvalid + } + if err := putter.Put(ctx, chunk); err != nil { + return swarm.ZeroAddress, mapHostErr(err) + } + return chunk.Address(), nil +} + +// putter returns the execution's upload session, opening it on the first put so +// a module that never uploads never creates one. +// +// One execution gets one session and therefore one batch: a put with a +// different batch than the one that opened it is refused. +func (h *executeHost) putter(ctx context.Context, batchID []byte) (storer.PutterSession, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.session != nil { + if !bytes.Equal(h.batchID, batchID) { + h.logger.Debug("execute host: second batch refused") + return nil, compute.ErrDenied + } + return h.session, nil + } + + // The upload store rejects a zero tag, so a session id is always allocated. + tag, err := h.s.getOrCreateSessionID(0) + if err != nil { + return nil, err + } + session, err := h.s.newStamperPutter(ctx, putterOptions{ + BatchID: batchID, + TagID: tag, + // Deferred: a put returns once the chunk is stored locally and the + // pusher syncs it afterwards. A direct upload would block on network + // round trips inside a host call and burn the watchdog. + Deferred: true, + }) + if err != nil { + return nil, mapHostErr(err) + } + + h.batchID = bytes.Clone(batchID) + h.session = session + return session, nil +} + +// Close finalises the upload session, if one was opened. A committed session +// hands its chunks to the pusher; otherwise they are dropped, so a module that +// trapped leaves nothing behind. +func (h *executeHost) Close(commit bool) error { + h.mu.Lock() + defer h.mu.Unlock() + + if h.session == nil { + return nil + } + session := h.session + h.session = nil + + if commit { + return session.Done(swarm.ZeroAddress) + } + return session.Cleanup() +} + +// mapHostErr translates a node error into the sentinel the guest may observe. +// Anything unrecognised is returned as-is and ends the execution as +// StatusHostError: a node-local failure is never a program verdict. +func mapHostErr(err error) error { + switch { + case errors.Is(err, storage.ErrNotFound), errors.Is(err, topology.ErrNotFound): + return errors.Join(compute.ErrNotFound, err) + case errors.Is(err, errBatchUnusable), + errors.Is(err, errInvalidPostageBatch), + errors.Is(err, postage.ErrNotFound), + errors.Is(err, postage.ErrNotUsable), + errors.Is(err, postage.ErrBucketFull), + errors.Is(err, postage.ErrInvalidBatchSignature): + return errors.Join(compute.ErrDenied, err) + default: + return err + } +} diff --git a/pkg/api/host_test.go b/pkg/api/host_test.go new file mode 100644 index 00000000000..d4d75642b46 --- /dev/null +++ b/pkg/api/host_test.go @@ -0,0 +1,302 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "bytes" + "context" + "encoding/binary" + "math/big" + "net/http" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/postage" + mockpost "github.com/ethersphere/bee/v2/pkg/postage/mock" + "github.com/ethersphere/bee/v2/pkg/storer" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// Guest-visible result codes of the swarm host module, mirrored from the ABI. +const ( + hostErrnoOK = 0 + hostErrnoDenied = 2 +) + +// loadHostFixture reads a WASM fixture from the compute package, which is where +// the guest ABI and its test modules are defined. See its testdata/README.md. +func loadHostFixture(t *testing.T, name string) []byte { + t.Helper() + + module, err := os.ReadFile(filepath.Join("..", "compute", "testdata", name+".wasm")) + if err != nil { + t.Fatal(err) + } + return module +} + +// sessionRecorder notes how an upload session was finished. The mock storer +// stores puts in a shared chunk store and its Cleanup is a no-op, so committing +// and discarding look identical from the outside; this records which one the +// handler actually chose. +type sessionRecorder struct { + storer.PutterSession + done *atomic.Bool + cleaned *atomic.Bool +} + +func (s sessionRecorder) Done(addr swarm.Address) error { + s.done.Store(true) + return s.PutterSession.Done(addr) +} + +func (s sessionRecorder) Cleanup() error { + s.cleaned.Store(true) + return s.PutterSession.Cleanup() +} + +// recordingStorer hands out recording upload sessions. +type recordingStorer struct { + api.Storer + done atomic.Bool + cleaned atomic.Bool +} + +func (r *recordingStorer) Upload(ctx context.Context, pin bool, tagID uint64) (storer.PutterSession, error) { + session, err := r.Storer.Upload(ctx, pin, tagID) + if err != nil { + return nil, err + } + return sessionRecorder{PutterSession: session, done: &r.done, cleaned: &r.cleaned}, nil +} + +// newHostTestServer wires the real wazero engine behind the execute endpoint so +// the host calls exercise the actual storer and postage paths. +// +// issuerBatch is the only batch the node will stamp with; a guest passing any +// other must be refused rather than served. +func newHostTestServer(t *testing.T, issuerBatch []byte) *http.Client { + t.Helper() + + client, _ := newHostTestServerWithStorer(t, issuerBatch) + return client +} + +func newHostTestServerWithStorer(t *testing.T, issuerBatch []byte) (*http.Client, *recordingStorer) { + t.Helper() + + engine, err := compute.New(compute.Options{Logger: log.Noop}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := engine.Close(); err != nil { + t.Errorf("close compute service: %v", err) + } + }) + + store := &recordingStorer{Storer: mockstorer.New()} + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: store, + Logger: log.Noop, + Post: mockpost.New(mockpost.WithIssuer(postage.NewStampIssuer("", "", issuerBatch, big.NewInt(3), 11, 10, 1000, true))), + Compute: engine, + }) + return client, store +} + +// runModule uploads a fixture and executes it with the given input, returning +// the raw bytes the module wrote. +func runModule(t *testing.T, client *http.Client, fixture string, input []byte, wantStatus int, wantWasmStatus string) []byte { + t.Helper() + + addr := uploadModule(t, client, loadHostFixture(t, fixture)) + + var out []byte + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), wantStatus, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + jsonhttptest.WithRequestBody(bytes.NewReader(input)), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, wantWasmStatus), + jsonhttptest.WithPutResponseBody(&out), + ) + return out +} + +// reset forgets the sessions used to upload the fixtures themselves, so a test +// observes only what the execution did. +func (r *recordingStorer) reset() { + r.done.Store(false) + r.cleaned.Store(false) +} + +// hostErrno reads the leading result code a fixture writes. +func hostErrno(t *testing.T, out []byte) uint32 { + t.Helper() + + if len(out) < 4 { + t.Fatalf("output too short for a result code: %d bytes", len(out)) + } + return binary.LittleEndian.Uint32(out) +} + +// TestExecuteHostBytesGet uploads data through /bytes and has a module read it +// back through swarm_bytes_get. +func TestExecuteHostBytesGet(t *testing.T) { + t.Parallel() + + client := newHostTestServer(t, batchOk) + payload := []byte("data the guest reads back out of swarm") + addr := uploadModule(t, client, payload) + + // stdin is the address followed by the buffer length the module offers. + input := append(addr.Bytes(), u32le(4096)...) + out := runModule(t, client, "hostbytesget", input, http.StatusOK, "ok") + + if errno := hostErrno(t, out); errno != hostErrnoOK { + t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK) + } + if got := out[8:]; !bytes.Equal(got, payload) { + t.Errorf("payload: got %q, want %q", got, payload) + } +} + +// TestExecuteHostBytesPut has a module upload data and then fetches the +// reference it returned over /bytes, which only resolves if the deferred +// session was committed after the run. +func TestExecuteHostBytesPut(t *testing.T) { + t.Parallel() + + client, store := newHostTestServerWithStorer(t, batchOk) + payload := []byte("data the guest wrote into swarm") + + addr := uploadModule(t, client, loadHostFixture(t, "hostbytesput")) + store.reset() + + var out []byte + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + jsonhttptest.WithRequestBody(bytes.NewReader(append(batchOk, payload...))), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + jsonhttptest.WithPutResponseBody(&out), + ) + + if errno := hostErrno(t, out); errno != hostErrnoOK { + t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK) + } + if !store.done.Load() { + t.Error("upload session was not committed after a clean run") + } + if store.cleaned.Load() { + t.Error("upload session was discarded after a clean run") + } + + ref := swarm.NewAddress(out[4:]) + if len(out[4:]) != swarm.HashSize { + t.Fatalf("reference length: got %d, want %d", len(out[4:]), swarm.HashSize) + } + + jsonhttptest.Request(t, client, http.MethodGet, "/bytes/"+ref.String(), http.StatusOK, + jsonhttptest.WithExpectedResponse(payload), + ) +} + +// TestExecuteHostBadBatch checks that an unusable batch is reported to the +// module as a result code, not surfaced as a node failure. +func TestExecuteHostBadBatch(t *testing.T) { + t.Parallel() + + client := newHostTestServer(t, batchOk) + + // A batch the node issues nothing for. The run itself is fine; only the + // upload is refused, so the endpoint answers 200 and the module reports it. + otherBatch := bytes.Repeat([]byte{9}, swarm.HashSize) + out := runModule(t, client, "hostbytesput", append(otherBatch, []byte("payload")...), http.StatusOK, "ok") + + if errno := hostErrno(t, out); errno != hostErrnoDenied { + t.Errorf("errno: got %d, want %d", errno, hostErrnoDenied) + } +} + +// TestExecuteHostTrapDiscardsUpload checks that a module which uploads and then +// traps leaves nothing behind: its chunks are dropped rather than handed to the +// pusher. +func TestExecuteHostTrapDiscardsUpload(t *testing.T) { + t.Parallel() + + client, store := newHostTestServerWithStorer(t, batchOk) + payload := []byte("data that must not survive the trap") + addr := uploadModule(t, client, loadHostFixture(t, "hostputtrap")) + store.reset() + + // A trap is a program verdict, so the endpoint answers 400. The JSON + // envelope still carries what the module wrote before trapping, which + // includes the reference its upload returned. + var resp struct { + Status string `json:"status"` + Output []byte `json:"output"` + } + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"), + jsonhttptest.WithRequestBody(bytes.NewReader(append(batchOk, payload...))), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + + if errno := hostErrno(t, resp.Output); errno != hostErrnoOK { + t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK) + } + if len(resp.Output[4:]) != swarm.HashSize { + t.Fatalf("reference length: got %d, want %d", len(resp.Output[4:]), swarm.HashSize) + } + + if store.done.Load() { + t.Error("upload session was committed after a trapped run") + } + if !store.cleaned.Load() { + t.Error("upload session was not discarded after a trapped run") + } +} + +// u32le encodes a little-endian uint32 the way the fixtures read them. +func u32le(v uint32) []byte { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, v) + return b +} + +// TestExecuteHostByteLimit checks that an object larger than the execution's +// byte budget is refused by the node before it is read into memory, and that +// the module sees that as a result code rather than a failure. +func TestExecuteHostByteLimit(t *testing.T) { + t.Parallel() + + const hostErrnoBudgetExhausted = 3 + + client := newHostTestServer(t, batchOk) + payload := bytes.Repeat([]byte("x"), 4096) + target := uploadModule(t, client, payload) + module := uploadModule(t, client, loadHostFixture(t, "hostbytesget")) + + var out []byte + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+module.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"), + // Far less than the payload, so the download is refused up front. + jsonhttptest.WithRequestHeader(api.SwarmWasmHostBytesHeader, "64"), + jsonhttptest.WithRequestBody(bytes.NewReader(append(target.Bytes(), u32le(4096)...))), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + jsonhttptest.WithPutResponseBody(&out), + ) + + if errno := hostErrno(t, out); errno != hostErrnoBudgetExhausted { + t.Errorf("errno: got %d, want %d", errno, hostErrnoBudgetExhausted) + } +} diff --git a/pkg/api/status.go b/pkg/api/status.go index c1eaacb49c0..c45b30c1d98 100644 --- a/pkg/api/status.go +++ b/pkg/api/status.go @@ -32,6 +32,7 @@ type statusSnapshotResponse struct { LastSyncedBlock uint64 `json:"lastSyncedBlock"` CommittedDepth uint8 `json:"committedDepth"` IsWarmingUp bool `json:"isWarmingUp"` + IsWasmEnabled bool `json:"isWasmEnabled"` } type statusResponse struct { @@ -92,6 +93,7 @@ func (s *Service) statusGetHandler(w http.ResponseWriter, _ *http.Request) { LastSyncedBlock: ss.LastSyncedBlock, CommittedDepth: uint8(ss.CommittedDepth), IsWarmingUp: s.isWarmingUp, + IsWasmEnabled: s.compute != nil, }) } diff --git a/pkg/api/status_test.go b/pkg/api/status_test.go index 9da5797caf8..19bf5a29cb8 100644 --- a/pkg/api/status_test.go +++ b/pkg/api/status_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" "github.com/ethersphere/bee/v2/pkg/log" "github.com/ethersphere/bee/v2/pkg/postage" @@ -24,57 +25,68 @@ func TestGetStatus(t *testing.T) { const url = "/status" - t.Run("node", func(t *testing.T) { - t.Parallel() - - mode := api.FullMode - ssr := api.StatusSnapshotResponse{ - Proximity: 256, - BeeMode: mode.String(), - ReserveSize: 128, - ReserveSizeWithinRadius: 64, - PullsyncRate: 64, - StorageRadius: 8, - ConnectedPeers: 0, - NeighborhoodSize: 1, - BatchCommitment: 1, - IsReachable: true, - LastSyncedBlock: 6092500, - CommittedDepth: 1, - } - - ssMock := &statusSnapshotMock{ - syncRate: ssr.PullsyncRate, - reserveSize: int(ssr.ReserveSize), - reserveSizeWithinRadius: ssr.ReserveSizeWithinRadius, - storageRadius: ssr.StorageRadius, - commitment: ssr.BatchCommitment, - chainState: &postage.ChainState{Block: ssr.LastSyncedBlock}, - committedDepth: ssr.CommittedDepth, - } - - statusSvc := status.NewService( - log.Noop, - nil, - new(topologyPeersIterNoopMock), - mode.String(), - ssMock, - ssMock, - nil, - ) - - statusSvc.SetSync(ssMock) - - client, _, _, _ := newTestServer(t, testServerOptions{ - BeeMode: mode, - NodeStatus: statusSvc, + for _, tc := range []struct { + name string + engine compute.Engine + }{ + {name: "node"}, + // The WASM engine is only wired up when the execute endpoint is enabled, + // so its presence is what the flag reports. + {name: "node with wasm engine", engine: new(mockEngine)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mode := api.FullMode + ssr := api.StatusSnapshotResponse{ + Proximity: 256, + BeeMode: mode.String(), + ReserveSize: 128, + ReserveSizeWithinRadius: 64, + PullsyncRate: 64, + StorageRadius: 8, + ConnectedPeers: 0, + NeighborhoodSize: 1, + BatchCommitment: 1, + IsReachable: true, + LastSyncedBlock: 6092500, + CommittedDepth: 1, + IsWasmEnabled: tc.engine != nil, + } + + ssMock := &statusSnapshotMock{ + syncRate: ssr.PullsyncRate, + reserveSize: int(ssr.ReserveSize), + reserveSizeWithinRadius: ssr.ReserveSizeWithinRadius, + storageRadius: ssr.StorageRadius, + commitment: ssr.BatchCommitment, + chainState: &postage.ChainState{Block: ssr.LastSyncedBlock}, + committedDepth: ssr.CommittedDepth, + } + + statusSvc := status.NewService( + log.Noop, + nil, + new(topologyPeersIterNoopMock), + mode.String(), + ssMock, + ssMock, + nil, + ) + + statusSvc.SetSync(ssMock) + + client, _, _, _ := newTestServer(t, testServerOptions{ + BeeMode: mode, + NodeStatus: statusSvc, + Compute: tc.engine, + }) + + jsonhttptest.Request(t, client, http.MethodGet, url, http.StatusOK, + jsonhttptest.WithExpectedJSONResponse(ssr), + ) }) - - jsonhttptest.Request(t, client, http.MethodGet, url, http.StatusOK, - jsonhttptest.WithExpectedJSONResponse(ssr), - ) - }) - + } } // TestGetStatusPeersIncludesBootnodes is a regression test for diff --git a/pkg/compute/README.md b/pkg/compute/README.md new file mode 100644 index 00000000000..552cf232923 --- /dev/null +++ b/pkg/compute/README.md @@ -0,0 +1,142 @@ +# The `swarm` guest ABI + +A module executed through `POST /@/{address}` runs in a WASI sandbox. Alongside +`wasi_snapshot_preview1` it may import a host module named `swarm`, through which +it reaches the node it is running on. + +> **Experimental.** Output is not reproducible across nodes: a host call reads +> what *this* node happens to hold. There is no gas metering and no process +> boundary. Node work is bounded by budgets, not by a work-based limit. Do not +> enable the endpoint on a public gateway. + +## Functions + +```wat +(import "swarm" "swarm_bytes_get" + (func (param i32 i32 i32 i32) (result i32))) ;; addr_ptr, buf_ptr, buf_len, out_len_ptr +(import "swarm" "swarm_bytes_put" + (func (param i32 i32 i32 i32) (result i32))) ;; batch_ptr, data_ptr, data_len, out_addr_ptr +(import "swarm" "swarm_chunk_get" + (func (param i32 i32 i32 i32) (result i32))) ;; addr_ptr, buf_ptr, buf_len, out_len_ptr +(import "swarm" "swarm_chunk_put" + (func (param i32 i32 i32 i32) (result i32))) ;; batch_ptr, data_ptr, data_len, out_addr_ptr +(import "swarm" "swarm_execute" + (func (param i32 i32 i32 i32 i32 i32) (result i32)));; addr_ptr, input_ptr, input_len, buf_ptr, buf_len, out_len_ptr +``` + +`bytes_*` moves data of arbitrary length through the same splitter and joiner +the `/bytes` endpoints use. `chunk_*` is the raw single-chunk pair: `chunk_put` +takes at most 4104 bytes (an 8-byte span followed by up to 4096 bytes of data) +and `chunk_get` yields a chunk's data verbatim. + +Addresses, references and batch ids are always 32 bytes, so they carry no length +argument. `out_addr_ptr` must have 32 writable bytes. + +Importing a name the host module does not define, or any module other than +`swarm` and `wasi_snapshot_preview1`, is rejected before the module runs — the +result is `invalid-module`, never a trap partway through. + +## Result codes + +Every function returns a code rather than trapping, so a module can react to a +refusal instead of dying: + +| Code | Name | Meaning | +|---|---|---| +| 0 | `OK` | the call succeeded | +| 1 | `NOT_FOUND` | nothing is stored at that address | +| 2 | `DENIED` | the node refused: an unusable postage batch, or a second batch in one execution | +| 3 | `BUDGET_EXHAUSTED` | the call, byte or depth budget is spent | +| 4 | `BUFFER_TOO_SMALL` | the payload does not fit `buf_len`; the required length is at `out_len_ptr` | +| 5 | `INVALID` | a pointer is out of bounds, or the arguments are malformed | +| 6 | `EXEC_FAILED` | `swarm_execute` ran the nested module and it trapped or was invalid | + +A pointer outside linear memory is `INVALID`, not a trap: the host bounds-checks +every offset it is given. + +Failures **inside the node** — the storer erroring, the watchdog firing — are +never reported through these codes. They end the execution with status +`host-error` and a 500, because a node-local failure is not a verdict on the +program. + +## Reading data: the two-call pattern + +The caller provides the buffer, so the host never grows guest memory mid-call. +`out_len_ptr` always receives the required length, including when the buffer was +too small, which gives the usual probe-then-fetch pattern without a second entry +point: + +```wat +;; probe with no buffer to learn the size +(call $bytes_get (local.get $addr) (i32.const 0) (i32.const 0) (i32.const 36)) +;; ... allocate (i32.load (i32.const 36)) bytes, then ask again +(call $bytes_get (local.get $addr) (local.get $buf) (local.get $len) (i32.const 36)) +``` + +A probe costs one host call but no bytes: the byte budget is charged only on +delivery. + +## Uploads + +The guest supplies the postage batch id, which it can only have received as +input — it has no way to enumerate the node's batches. The node resolves it +exactly as `POST /chunks` does, so the WASM path grants no authority the HTTP +API does not already grant. An unusable or unknown batch is `DENIED`. + +One execution gets **one upload session and therefore one batch**: a put naming a +different batch than the first is `DENIED`. + +Uploads are **deferred**. A put returns once the chunk is stored locally and the +pusher syncs it afterwards, so a host call never blocks on network round trips. +Two consequences: + +- When the HTTP response returns, the data is in the local upload store but not + yet acknowledged by the network. +- The session is committed only if the execution succeeds. A module that traps, + or is cut off, leaves nothing behind. + +Encryption and redundancy are not exposed: `bytes_put` always writes +unencrypted at the default redundancy level, which is what keeps a reference +32 bytes wide. + +## Nested execution + +`swarm_execute` fetches a module from Swarm and runs it, handing it `input` on +stdin and returning its stdout. The budgets are **shared across the whole call +tree**, so a module cannot multiply its allowance by recursing. Nesting is +bounded by the depth limit; a cycle simply runs out of depth. + +A nested module is always run as a WASI command: the caller's +`Swarm-Wasm-Entrypoint` applies to the outermost module only. + +## Budgets + +| Bound | Default | Header | Stops | +|---|---|---|---| +| host calls | 64 | `Swarm-Wasm-Host-Calls-Limit` | fetch amplification | +| host bytes | 32 MiB | `Swarm-Wasm-Host-Bytes-Limit` | memory and bandwidth blowup | +| depth | 4 | `Swarm-Wasm-Depth-Limit` | runaway recursion | + +Headers may only lower a limit; the operator's configured maximum wins. The byte +budget is one pool counting both directions — what the node hands the guest and +what it accepts from it. An upload is charged its declared length before the +splitter runs, so an oversized put is refused without the node ever chunking it. + +The depth limit counts execution levels including the outermost, so `1` permits +no nesting at all. + +## WASI + +The whole of `wasi_snapshot_preview1` is available, `random_get` and +`clock_time_get` included, which is what lets modules built by Rust std, TinyGo +and Go run without special builds. That is a prototype convenience, not a +portability guarantee: a deterministic engine would restrict this surface. + +Request metadata is exposed CGI-style: `REQUEST_METHOD` carries the HTTP method +the endpoint was called with. The host environment is never inherited. + +## Examples + +Hand-written fixtures covering every call and result code live in +[`testdata/`](testdata/), with a table of their stdin and stdout layouts in +[`testdata/README.md`](testdata/README.md). diff --git a/pkg/compute/compute.go b/pkg/compute/compute.go index e42ffccbb5b..448c412acd4 100644 --- a/pkg/compute/compute.go +++ b/pkg/compute/compute.go @@ -24,7 +24,7 @@ type Options struct { // Workers bounds the number of concurrent executions. Values < 1 become 1. Workers int // Watchdog is a wall-clock safety timeout that kills a hung execution. It is - // NOT a deterministic budget (see fuel) and a kill yields StatusHostError. + // NOT a deterministic budget and a kill yields StatusHostError. Watchdog time.Duration // Logger is used for operator diagnostics. Logger log.Logger diff --git a/pkg/compute/compute_test.go b/pkg/compute/compute_test.go index ec4040e11df..478dec5b8d6 100644 --- a/pkg/compute/compute_test.go +++ b/pkg/compute/compute_test.go @@ -293,7 +293,6 @@ func TestStatusString(t *testing.T) { want string }{ {compute.StatusOK, "ok"}, - {compute.StatusOutOfFuel, "out-of-fuel"}, {compute.StatusTrap, "trap"}, {compute.StatusInvalidModule, "invalid-module"}, {compute.StatusHostError, "host-error"}, diff --git a/pkg/compute/engine.go b/pkg/compute/engine.go index ef23e0563d0..6dee2352a06 100644 --- a/pkg/compute/engine.go +++ b/pkg/compute/engine.go @@ -3,22 +3,30 @@ // license that can be found in the LICENSE file. // Package compute runs untrusted WebAssembly modules downloaded from Swarm in a -// sandboxed execution engine and returns the deterministic result of the -// computation. +// sandboxed execution engine and returns the result of the computation. // -// This is the phase-0 skeleton: it executes modules in-process with wazero and -// does NOT yet enforce deterministic gas metering. It is intended to validate -// the API, download and wiring path end-to-end and must not be relied upon for -// reproducible-across-nodes output. A later phase replaces the engine with an -// out-of-process wasmtime worker that meters execution by deterministic fuel. +// This is an experimental prototype. Modules run in-process with wazero and may +// call back into the node through the swarm host module to read and write Swarm +// data (see Host). Two properties the production design calls for are therefore +// absent, deferred rather than rejected: +// +// - Output is NOT reproducible across nodes. A host call reads what this node +// happens to hold, and wazero has no gas metering, so there is no +// deterministic budget bounding the work a module may do. +// - There is no process boundary. An engine escape lands in the node's own +// address space, which is tolerable only because wazero is pure Go and +// memory-safe. Keep the endpoint off public gateways. +// +// What does hold: node work is bounded (a concurrency semaphore, a wall-clock +// watchdog and the host-call budgets in Limits), and a node-local failure is +// never laundered into a program verdict — it surfaces as StatusHostError. package compute import "context" // Status classifies the outcome of a WASM execution. // -// StatusOK, StatusOutOfFuel, StatusTrap and StatusInvalidModule are program -// verdicts and are intended to be deterministic across nodes. StatusHostError +// StatusOK, StatusTrap and StatusInvalidModule are program verdicts and are intended to be deterministic across nodes. StatusHostError // signals an infrastructure failure local to this node (spawn failure, watchdog // kill, IPC error) and must never be treated as a program result. type Status uint8 @@ -26,8 +34,6 @@ type Status uint8 const ( // StatusOK indicates the module ran to completion and produced output. StatusOK Status = iota + 1 - // StatusOutOfFuel indicates the module exceeded its deterministic gas budget. - StatusOutOfFuel // StatusTrap indicates the module trapped (unreachable, out-of-bounds, non-zero exit, ...). StatusTrap // StatusInvalidModule indicates the bytes failed validation/compilation or import checks. @@ -41,8 +47,6 @@ func (s Status) String() string { switch s { case StatusOK: return "ok" - case StatusOutOfFuel: - return "out-of-fuel" case StatusTrap: return "trap" case StatusInvalidModule: @@ -56,17 +60,17 @@ func (s Status) String() string { // Result is the outcome of executing a module. type Result struct { - Status Status - Output []byte - FuelConsumed uint64 - TrapMessage string + Status Status + Output []byte + TrapMessage string } // Request describes a single execution: the module to run, the caller-supplied // input and the request metadata the module is allowed to observe. // -// Every field is derived from the incoming HTTP request, never from the host, so -// the same Request produces the same Result on every node. +// Every field but Host is derived from the incoming HTTP request. Host calls +// read node-local state, so a module using them is not reproducible across +// nodes; see the package documentation. type Request struct { // Module is the WASM binary to execute. Module []byte @@ -78,6 +82,12 @@ type Request struct { Input []byte // Limits bound the execution. Limits Limits + // Host serves the calls the module makes back into the node. It is + // per-request: uploads it performs belong to one execution and no more. + // + // A nil Host leaves the swarm module uninstantiated, so a module importing + // it is StatusInvalidModule rather than trapping mid-run. + Host Host } // Engine executes a single WASM module in isolation and returns its Result. diff --git a/pkg/compute/export_test.go b/pkg/compute/export_test.go new file mode 100644 index 00000000000..5bb86265e8a --- /dev/null +++ b/pkg/compute/export_test.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute + +import ( + "context" + + "github.com/tetratelabs/wazero" +) + +// SwarmExports is the import allowlist checkImports enforces. +var SwarmExports = swarmExports + +// SwarmModuleExports instantiates the swarm host module and reports the names +// it actually defines, so a test can hold it against SwarmExports. +func SwarmModuleExports(ctx context.Context) ([]string, error) { + r := wazero.NewRuntime(ctx) + defer r.Close(ctx) + + if err := buildSwarmModule(ctx, r, &hostState{}); err != nil { + return nil, err + } + + var names []string + for name := range r.Module(swarmModuleName).ExportedFunctionDefinitions() { + names = append(names, name) + } + return names, nil +} diff --git a/pkg/compute/host.go b/pkg/compute/host.go new file mode 100644 index 00000000000..ecb336a6119 --- /dev/null +++ b/pkg/compute/host.go @@ -0,0 +1,373 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute + +import ( + "context" + "errors" + + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// Host serves the calls a running module makes back into the node. +// +// A nil Host means the swarm module is not instantiated at all, so a module +// importing it is rejected as invalid rather than trapping mid-run. +// +// Implementations report guest-caused outcomes with the ErrNotFound, ErrDenied +// and ErrInvalid sentinels. Any other error is treated as a node-local failure: +// it aborts the whole execution as StatusHostError and is never laundered into +// a program verdict. +type Host interface { + // BytesGet reassembles data of arbitrary length addressed by addr. + BytesGet(ctx context.Context, addr swarm.Address) ([]byte, error) + // BytesPut splits data of arbitrary length, stamps it with batchID and + // returns the root reference. + BytesPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) + // ChunkGet retrieves a single chunk verbatim. + ChunkGet(ctx context.Context, addr swarm.Address) ([]byte, error) + // ChunkPut stores a single content-addressed chunk verbatim. + ChunkPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) +} + +// Sentinel errors a Host returns to describe a guest-caused failure. They are +// the only errors a module can observe; everything else ends the run. +var ( + // ErrNotFound reports that the addressed data does not exist. + ErrNotFound = errors.New("compute: not found") + // ErrDenied reports that the node refused the call, e.g. an unusable + // postage batch or a second batch in one execution. + ErrDenied = errors.New("compute: denied") + // ErrInvalid reports malformed guest-supplied arguments. + ErrInvalid = errors.New("compute: invalid argument") + // ErrTooLarge reports a payload the execution's byte budget cannot cover. + // A Host uses it to refuse work before materialising it, which is why the + // budget alone is not enough: it is charged only on delivery. + ErrTooLarge = errors.New("compute: payload too large") +) + +// swarmModuleName is the host module through which a guest reaches the node. +const swarmModuleName = "swarm" + +// Guest-visible result codes returned by every swarm host function. +const ( + errnoOK uint32 = 0 + errnoNotFound uint32 = 1 + errnoDenied uint32 = 2 + errnoBudgetExhausted uint32 = 3 + errnoBufferTooSmall uint32 = 4 + errnoInvalid uint32 = 5 + errnoExecFailed uint32 = 6 +) + +// exitCodeHostAbort stops a module whose host call hit a node-local failure. +// It is only a mechanism to unwind: hostState.err is the source of truth, so a +// guest calling proc_exit with the same code is never mistaken for a host error. +const exitCodeHostAbort uint32 = 0xBEE0 + +// swarmExports is the exact set of functions the swarm host module defines. +// checkImports rejects anything outside it before instantiation, so an unknown +// swarm import is a deterministic StatusInvalidModule rather than a link trap. +// TestSwarmExportsMatchBuilder keeps this in step with buildSwarmModule. +var swarmExports = map[string]struct{}{ + "swarm_bytes_get": {}, + "swarm_bytes_put": {}, + "swarm_chunk_get": {}, + "swarm_chunk_put": {}, + "swarm_execute": {}, +} + +// budget bounds the node work one execution tree may cause. It is shared by +// pointer across nested executions so a module cannot multiply its allowance by +// recursing. +type budget struct { + calls uint32 + bytes uint64 +} + +func newBudget(l Limits) *budget { + return &budget{calls: l.HostCalls(), bytes: l.HostBytes()} +} + +// useCall charges one host call, reporting whether the budget allowed it. +func (b *budget) useCall() bool { + if b.calls == 0 { + return false + } + b.calls-- + return true +} + +// useBytes charges n bytes, reporting whether the budget allowed it. The charge +// is all-or-nothing so a rejected call consumes nothing. +func (b *budget) useBytes(n uint64) bool { + if n > b.bytes { + return false + } + b.bytes -= n + return true +} + +// nestedFunc runs a module fetched by swarm_execute, re-entering the engine. +type nestedFunc func(ctx context.Context, module, input []byte) (Result, error) + +// hostState backs the swarm host module for a single execution tree. A fresh +// one is built per execution, so nothing is shared between untrusted programs. +type hostState struct { + host Host + budget *budget + nested nestedFunc + // depth is the current nesting level; 0 is the outermost execution. + depth uint32 + // maxDepth bounds the number of execution levels, the outermost included. + maxDepth uint32 + logger log.Logger + // err records a node-local failure. When set, the run is aborted and its + // verdict is StatusHostError regardless of how wazero reports the unwind. + err error +} + +// abort records a node-local failure and stops the module. The errno it returns +// is never observed: the guest is being torn down. +func (h *hostState) abort(ctx context.Context, mod api.Module, err error) uint32 { + if h.err == nil { + h.err = err + } + h.logger.Debug("host call failed", "error", err) + _ = mod.CloseWithExitCode(ctx, exitCodeHostAbort) + return errnoInvalid +} + +// classify maps a Host error to a guest-visible errno. The bool is false when +// the error is node-local and the run must be aborted instead. +func classifyHostErr(err error) (uint32, bool) { + switch { + case errors.Is(err, ErrNotFound): + return errnoNotFound, true + case errors.Is(err, ErrDenied): + return errnoDenied, true + case errors.Is(err, ErrInvalid): + return errnoInvalid, true + case errors.Is(err, ErrTooLarge): + return errnoBudgetExhausted, true + default: + return 0, false + } +} + +// buildSwarmModule instantiates the swarm host module against the runtime. +func buildSwarmModule(ctx context.Context, r wazero.Runtime, h *hostState) error { + _, err := r.NewHostModuleBuilder(swarmModuleName). + NewFunctionBuilder(). + WithFunc(h.bytesGet). + WithParameterNames("addr_ptr", "buf_ptr", "buf_len", "out_len_ptr"). + Export("swarm_bytes_get"). + NewFunctionBuilder(). + WithFunc(h.bytesPut). + WithParameterNames("batch_ptr", "data_ptr", "data_len", "out_addr_ptr"). + Export("swarm_bytes_put"). + NewFunctionBuilder(). + WithFunc(h.chunkGet). + WithParameterNames("addr_ptr", "buf_ptr", "buf_len", "out_len_ptr"). + Export("swarm_chunk_get"). + NewFunctionBuilder(). + WithFunc(h.chunkPut). + WithParameterNames("batch_ptr", "data_ptr", "data_len", "out_addr_ptr"). + Export("swarm_chunk_put"). + NewFunctionBuilder(). + WithFunc(h.execute). + WithParameterNames("addr_ptr", "input_ptr", "input_len", "buf_ptr", "buf_len", "out_len_ptr"). + Export("swarm_execute"). + Instantiate(ctx) + return err +} + +func (h *hostState) bytesGet(ctx context.Context, mod api.Module, addrPtr, bufPtr, bufLen, outLenPtr uint32) uint32 { + return h.get(ctx, mod, addrPtr, bufPtr, bufLen, outLenPtr, h.host.BytesGet) +} + +func (h *hostState) chunkGet(ctx context.Context, mod api.Module, addrPtr, bufPtr, bufLen, outLenPtr uint32) uint32 { + return h.get(ctx, mod, addrPtr, bufPtr, bufLen, outLenPtr, h.host.ChunkGet) +} + +func (h *hostState) bytesPut(ctx context.Context, mod api.Module, batchPtr, dataPtr, dataLen, outAddrPtr uint32) uint32 { + return h.put(ctx, mod, batchPtr, dataPtr, dataLen, outAddrPtr, 0, h.host.BytesPut) +} + +func (h *hostState) chunkPut(ctx context.Context, mod api.Module, batchPtr, dataPtr, dataLen, outAddrPtr uint32) uint32 { + return h.put(ctx, mod, batchPtr, dataPtr, dataLen, outAddrPtr, swarm.ChunkWithSpanSize, h.host.ChunkPut) +} + +// get is the shared body of the address-in, data-out calls. +// +// A call is charged whether or not the data fits the guest buffer; bytes are +// charged only when they are actually delivered, so the probe-then-fetch +// pattern (buf_len 0, read out_len, retry) costs two calls but pays for the +// payload once. +func (h *hostState) get( + ctx context.Context, + mod api.Module, + addrPtr, bufPtr, bufLen, outLenPtr uint32, + fn func(context.Context, swarm.Address) ([]byte, error), +) uint32 { + if !h.budget.useCall() { + return errnoBudgetExhausted + } + + mem := mod.Memory() + addr, ok := readAddress(mem, addrPtr) + if !ok { + return errnoInvalid + } + + data, err := fn(ctx, addr) + if err != nil { + if code, guest := classifyHostErr(err); guest { + return code + } + return h.abort(ctx, mod, err) + } + + return h.deliver(ctx, mod, bufPtr, bufLen, outLenPtr, data) +} + +// deliver writes data into the guest buffer, always reporting the required +// length through outLenPtr so a too-small buffer can be retried. +func (h *hostState) deliver(ctx context.Context, mod api.Module, bufPtr, bufLen, outLenPtr uint32, data []byte) uint32 { + mem := mod.Memory() + if !mem.WriteUint32Le(outLenPtr, uint32(len(data))) { + return errnoInvalid + } + if uint64(len(data)) > uint64(bufLen) { + return errnoBufferTooSmall + } + if !h.budget.useBytes(uint64(len(data))) { + return errnoBudgetExhausted + } + if !mem.Write(bufPtr, data) { + return errnoInvalid + } + return errnoOK +} + +// put is the shared body of the data-in, address-out calls. maxLen caps the +// accepted payload; 0 means only the byte budget applies. +func (h *hostState) put( + ctx context.Context, + mod api.Module, + batchPtr, dataPtr, dataLen, outAddrPtr uint32, + maxLen uint32, + fn func(context.Context, []byte, []byte) (swarm.Address, error), +) uint32 { + if !h.budget.useCall() { + return errnoBudgetExhausted + } + if maxLen > 0 && dataLen > maxLen { + return errnoInvalid + } + // Charge the declared length before doing any work, so an oversized put is + // refused without the node ever chunking it. + if !h.budget.useBytes(uint64(dataLen)) { + return errnoBudgetExhausted + } + + mem := mod.Memory() + batchID, ok := mem.Read(batchPtr, swarm.HashSize) + if !ok { + return errnoInvalid + } + data, ok := mem.Read(dataPtr, dataLen) + if !ok { + return errnoInvalid + } + + // Memory.Read aliases the guest's memory; the storer keeps what it is given, + // so hand it a copy the guest cannot mutate underneath it. + addr, err := fn(ctx, bytesClone(batchID), bytesClone(data)) + if err != nil { + if code, guest := classifyHostErr(err); guest { + return code + } + return h.abort(ctx, mod, err) + } + + if !mem.Write(outAddrPtr, addr.Bytes()) { + return errnoInvalid + } + return errnoOK +} + +// execute fetches a module from Swarm and runs it as a nested execution sharing +// this tree's budget. +func (h *hostState) execute(ctx context.Context, mod api.Module, addrPtr, inputPtr, inputLen, bufPtr, bufLen, outLenPtr uint32) uint32 { + if !h.budget.useCall() { + return errnoBudgetExhausted + } + // maxDepth counts execution levels, the outermost included, so a limit of 1 + // permits no nesting at all. + if h.depth+1 >= h.maxDepth { + return errnoBudgetExhausted + } + if !h.budget.useBytes(uint64(inputLen)) { + return errnoBudgetExhausted + } + + mem := mod.Memory() + addr, ok := readAddress(mem, addrPtr) + if !ok { + return errnoInvalid + } + input, ok := mem.Read(inputPtr, inputLen) + if !ok { + return errnoInvalid + } + input = bytesClone(input) + + module, err := h.host.BytesGet(ctx, addr) + if err != nil { + if code, guest := classifyHostErr(err); guest { + return code + } + return h.abort(ctx, mod, err) + } + if !h.budget.useBytes(uint64(len(module))) { + return errnoBudgetExhausted + } + + res, err := h.nested(ctx, module, input) + if err != nil { + return h.abort(ctx, mod, err) + } + switch res.Status { + case StatusOK: + return h.deliver(ctx, mod, bufPtr, bufLen, outLenPtr, res.Output) + case StatusHostError: + // A node-local failure inside the child is a node-local failure here. + return h.abort(ctx, mod, errors.New("nested execution failed")) + default: + // The child trapped or was invalid. That is a verdict + // on the child, which the caller may handle. + return errnoExecFailed + } +} + +// readAddress reads a fixed-width Swarm address out of guest memory. +func readAddress(mem api.Memory, ptr uint32) (swarm.Address, bool) { + b, ok := mem.Read(ptr, swarm.HashSize) + if !ok { + return swarm.ZeroAddress, false + } + return swarm.NewAddress(bytesClone(b)), true +} + +// bytesClone copies a slice aliasing guest memory. +func bytesClone(b []byte) []byte { + out := make([]byte, len(b)) + copy(out, b) + return out +} diff --git a/pkg/compute/host_test.go b/pkg/compute/host_test.go new file mode 100644 index 00000000000..4a43e0a7378 --- /dev/null +++ b/pkg/compute/host_test.go @@ -0,0 +1,570 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute_test + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "sort" + "sync" + "testing" + + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// Guest-visible result codes, mirrored from the ABI so a test asserts the +// number a module actually observes. +const ( + errnoOK = 0 + errnoNotFound = 1 + errnoDenied = 2 + errnoBudgetExhausted = 3 + errnoBufferTooSmall = 4 + errnoInvalid = 5 + errnoExecFailed = 6 +) + +// mockHost serves canned data and records what the guest asked for. +type mockHost struct { + data map[string][]byte + chunks map[string][]byte + + // err, when set, is returned by every call, standing in for a node-local + // failure that must never become a program verdict. + err error + // denyPut makes the puts report a refused batch. + denyPut bool + + bytesGets int + puts [][]byte +} + +func newMockHost() *mockHost { + return &mockHost{data: map[string][]byte{}, chunks: map[string][]byte{}} +} + +// addData stores payload under a synthetic address derived from seed. +func (m *mockHost) addData(seed byte, payload []byte) swarm.Address { + addr := addressOf(seed) + m.data[addr.String()] = payload + return addr +} + +func (m *mockHost) BytesGet(_ context.Context, addr swarm.Address) ([]byte, error) { + m.bytesGets++ + if m.err != nil { + return nil, m.err + } + payload, ok := m.data[addr.String()] + if !ok { + return nil, compute.ErrNotFound + } + return payload, nil +} + +func (m *mockHost) BytesPut(_ context.Context, batchID, data []byte) (swarm.Address, error) { + if m.err != nil { + return swarm.ZeroAddress, m.err + } + if m.denyPut { + return swarm.ZeroAddress, compute.ErrDenied + } + m.puts = append(m.puts, data) + addr := addressOf(byte(len(m.data) + 1)) + m.data[addr.String()] = data + return addr, nil +} + +func (m *mockHost) ChunkGet(_ context.Context, addr swarm.Address) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + chunk, ok := m.chunks[addr.String()] + if !ok { + return nil, compute.ErrNotFound + } + return chunk, nil +} + +func (m *mockHost) ChunkPut(_ context.Context, batchID, data []byte) (swarm.Address, error) { + if m.err != nil { + return swarm.ZeroAddress, m.err + } + if m.denyPut { + return swarm.ZeroAddress, compute.ErrDenied + } + addr := addressOf(byte(len(m.chunks) + 100)) + m.chunks[addr.String()] = data + return addr, nil +} + +// addressOf builds a distinct, readable 32-byte address from a single byte. +func addressOf(seed byte) swarm.Address { + b := make([]byte, swarm.HashSize) + for i := range b { + b[i] = seed + } + return swarm.NewAddress(b) +} + +// u32 encodes a little-endian uint32 the way the fixtures read and write them. +func u32(v uint32) []byte { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, v) + return b +} + +// runHost executes a fixture against a host and returns its raw stdout. +func runHost(t *testing.T, host compute.Host, module string, input []byte, limits compute.Limits) compute.Result { + t.Helper() + + s := newService(t, compute.Options{}) + res, err := s.Execute(t.Context(), compute.Request{ + Module: loadModule(t, module), + Input: input, + Limits: limits, + Host: host, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + return res +} + +// splitOutput cuts the leading fixed-width fields off a fixture's stdout. +func splitOutput(t *testing.T, out []byte, fields int) ([]uint32, []byte) { + t.Helper() + + if len(out) < fields*4 { + t.Fatalf("output too short: got %d bytes, want at least %d", len(out), fields*4) + } + values := make([]uint32, fields) + for i := range values { + values[i] = binary.LittleEndian.Uint32(out[i*4:]) + } + return values, out[fields*4:] +} + +func TestHostBytesGet(t *testing.T) { + t.Parallel() + + payload := []byte("data reached the guest") + + for _, tc := range []struct { + name string + addr swarm.Address + bufLen uint32 + wantErrno uint32 + wantLen uint32 + wantData []byte + }{ + { + name: "delivers the payload", + addr: addressOf(1), + bufLen: 4096, + wantErrno: errnoOK, + wantLen: uint32(len(payload)), + wantData: payload, + }, + { + name: "missing address", + addr: addressOf(9), + bufLen: 4096, + wantErrno: errnoNotFound, + }, + { + name: "buffer too small reports the required length", + addr: addressOf(1), + // The probe half of the two-call pattern: no buffer at all, so the + // guest learns how much to allocate before asking again. + bufLen: 0, + wantErrno: errnoBufferTooSmall, + wantLen: uint32(len(payload)), + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + host := newMockHost() + host.addData(1, payload) + + res := runHost(t, host, "hostbytesget", append(tc.addr.Bytes(), u32(tc.bufLen)...), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != tc.wantErrno { + t.Errorf("errno: got %d, want %d", fields[0], tc.wantErrno) + } + if fields[1] != tc.wantLen { + t.Errorf("required length: got %d, want %d", fields[1], tc.wantLen) + } + if !bytes.Equal(data, tc.wantData) { + t.Errorf("payload: got %q, want %q", data, tc.wantData) + } + }) + } +} + +func TestHostBytesPut(t *testing.T) { + t.Parallel() + + payload := []byte("uploaded by the guest") + batch := bytes.Repeat([]byte{7}, swarm.HashSize) + + t.Run("stores the payload", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + res := runHost(t, host, "hostbytesput", append(batch, payload...), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, ref := splitOutput(t, res.Output, 1) + if fields[0] != errnoOK { + t.Fatalf("errno: got %d, want %d", fields[0], errnoOK) + } + if len(ref) != swarm.HashSize { + t.Fatalf("reference length: got %d, want %d", len(ref), swarm.HashSize) + } + if len(host.puts) != 1 || !bytes.Equal(host.puts[0], payload) { + t.Fatalf("stored payload: got %q", host.puts) + } + // The reference must resolve to what was stored. + if got := host.data[swarm.NewAddress(ref).String()]; !bytes.Equal(got, payload) { + t.Errorf("reference resolves to %q, want %q", got, payload) + } + }) + + t.Run("refused batch is a guest-visible verdict", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + host.denyPut = true + + res := runHost(t, host, "hostbytesput", append(batch, payload...), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + fields, _ := splitOutput(t, res.Output, 1) + if fields[0] != errnoDenied { + t.Errorf("errno: got %d, want %d", fields[0], errnoDenied) + } + }) +} + +func TestHostChunkRoundTrip(t *testing.T) { + t.Parallel() + + batch := bytes.Repeat([]byte{7}, swarm.HashSize) + // An 8-byte span followed by the chunk payload, as the chunk API expects. + chunk := append(u32(11), u32(0)...) + chunk = append(chunk, []byte("chunk bytes")...) + + host := newMockHost() + res := runHost(t, host, "hostchunk", append(batch, chunk...), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != errnoOK { + t.Fatalf("put errno: got %d, want %d", fields[0], errnoOK) + } + if fields[1] != errnoOK { + t.Fatalf("get errno: got %d, want %d", fields[1], errnoOK) + } + if !bytes.Equal(data, chunk) { + t.Errorf("retrieved chunk: got %q, want %q", data, chunk) + } +} + +func TestHostCallBudget(t *testing.T) { + t.Parallel() + + host := newMockHost() + addr := host.addData(1, []byte("x")) + + const allowed = 3 + res := runHost(t, host, "hostcalls", addr.Bytes(), compute.Limits{MaxHostCalls: allowed}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, _ := splitOutput(t, res.Output, 2) + if fields[0] != allowed { + t.Errorf("successful calls: got %d, want %d", fields[0], allowed) + } + if fields[1] != errnoBudgetExhausted { + t.Errorf("errno: got %d, want %d", fields[1], errnoBudgetExhausted) + } + // The host must not have been asked to do work beyond the budget. + if host.bytesGets != allowed { + t.Errorf("host calls reaching the node: got %d, want %d", host.bytesGets, allowed) + } +} + +func TestHostByteBudget(t *testing.T) { + t.Parallel() + + payload := bytes.Repeat([]byte("a"), 512) + host := newMockHost() + addr := host.addData(1, payload) + + // Enough for one delivery, not two. + res := runHost(t, host, "hostcalls", addr.Bytes(), compute.Limits{MaxHostBytes: 600}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, _ := splitOutput(t, res.Output, 2) + if fields[0] != 1 { + t.Errorf("successful calls: got %d, want 1", fields[0]) + } + if fields[1] != errnoBudgetExhausted { + t.Errorf("errno: got %d, want %d", fields[1], errnoBudgetExhausted) + } +} + +func TestHostBadPointer(t *testing.T) { + t.Parallel() + + // A pointer outside linear memory is the guest's mistake: it must come back + // as a result code, not tear the module down. + res := runHost(t, newMockHost(), "hostbadptr", nil, compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, _ := splitOutput(t, res.Output, 1) + if fields[0] != errnoInvalid { + t.Errorf("errno: got %d, want %d", fields[0], errnoInvalid) + } +} + +func TestHostErrorIsNeverATrap(t *testing.T) { + t.Parallel() + + // The central invariant: a node-local failure ends the run as a host error + // with a non-nil error. Reporting it as a trap would tell every caller the + // program was at fault, which is a verdict this node has no right to make. + host := newMockHost() + host.addData(1, []byte("unreachable")) + host.err = errors.New("storer exploded") + + s := newService(t, compute.Options{}) + res, err := s.Execute(t.Context(), compute.Request{ + Module: loadModule(t, "hostbytesget"), + Input: append(addressOf(1).Bytes(), u32(4096)...), + Host: host, + }) + if err == nil { + t.Fatal("expected a non-nil error for a node-local failure") + } + if res.Status != compute.StatusHostError { + t.Errorf("status: got %v, want %v", res.Status, compute.StatusHostError) + } +} + +func TestHostUnavailable(t *testing.T) { + t.Parallel() + + // With no Host the swarm module is not instantiated, so a module importing + // it is rejected up front rather than trapping mid-run. + s := newService(t, compute.Options{}) + res, err := s.Execute(t.Context(), compute.Request{ + Module: loadModule(t, "hostbytesget"), + Input: append(addressOf(1).Bytes(), u32(4096)...), + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Status != compute.StatusInvalidModule { + t.Errorf("status: got %v, want %v", res.Status, compute.StatusInvalidModule) + } +} + +func TestHostUnknownImport(t *testing.T) { + t.Parallel() + + res := runHost(t, newMockHost(), "hostunknown", nil, compute.Limits{}) + if res.Status != compute.StatusInvalidModule { + t.Errorf("status: got %v, want %v", res.Status, compute.StatusInvalidModule) + } +} + +func TestSwarmExportsMatchBuilder(t *testing.T) { + t.Parallel() + + // checkImports rejects swarm imports outside the allowlist before the + // module is instantiated. If the allowlist and the builder drift apart, a + // real function becomes unreachable or a missing one becomes a link trap. + defined, err := compute.SwarmModuleExports(t.Context()) + if err != nil { + t.Fatal(err) + } + + allowed := make([]string, 0, len(compute.SwarmExports)) + for name := range compute.SwarmExports { + allowed = append(allowed, name) + } + sort.Strings(allowed) + sort.Strings(defined) + + if len(allowed) != len(defined) { + t.Fatalf("allowlist %v, host module defines %v", allowed, defined) + } + for i := range allowed { + if allowed[i] != defined[i] { + t.Errorf("allowlist %v, host module defines %v", allowed, defined) + break + } + } +} + +func TestHostNestedExecute(t *testing.T) { + t.Parallel() + + t.Run("output of the nested module is forwarded", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + addr := host.addData(2, loadModule(t, "echo")) + + res := runHost(t, host, "hostnested", append(addr.Bytes(), []byte("nested input")...), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != errnoOK { + t.Fatalf("errno: got %d, want %d", fields[0], errnoOK) + } + if !bytes.Equal(data, []byte("nested input")) { + t.Errorf("nested output: got %q, want %q", data, "nested input") + } + }) + + t.Run("depth limit refuses nesting", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + addr := host.addData(2, loadModule(t, "echo")) + + // One level means the outermost execution and nothing below it. + res := runHost(t, host, "hostnested", append(addr.Bytes(), []byte("x")...), compute.Limits{MaxDepth: 1}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + fields, _ := splitOutput(t, res.Output, 2) + if fields[0] != errnoBudgetExhausted { + t.Errorf("errno: got %d, want %d", fields[0], errnoBudgetExhausted) + } + }) + + t.Run("a nested trap is a verdict, not a host failure", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + addr := host.addData(2, loadModule(t, "trap")) + + res := runHost(t, host, "hostnested", addr.Bytes(), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + fields, _ := splitOutput(t, res.Output, 2) + if fields[0] != errnoExecFailed { + t.Errorf("errno: got %d, want %d", fields[0], errnoExecFailed) + } + }) + + t.Run("the call budget is shared with the nested module", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + module := host.addData(2, loadModule(t, "hostcalls")) + payload := host.addData(3, []byte("y")) + + // Three calls: swarm_execute takes one, leaving the nested module two + // before it is cut off. Were the budget rebuilt per execution the + // nested module would get all three. + res := runHost(t, host, "hostnested", append(module.Bytes(), payload.Bytes()...), compute.Limits{MaxHostCalls: 3}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != errnoOK { + t.Fatalf("errno: got %d, want %d", fields[0], errnoOK) + } + nested, _ := splitOutput(t, data, 2) + if nested[0] != 2 { + t.Errorf("nested successful calls: got %d, want 2", nested[0]) + } + if nested[1] != errnoBudgetExhausted { + t.Errorf("nested errno: got %d, want %d", nested[1], errnoBudgetExhausted) + } + }) +} + +// TestHostConcurrentExecutions runs many executions through one Service at once. +// Each host module closes over its own call's state, so a shared budget or a +// shared runtime would show up here as crossed results or a race. +func TestHostConcurrentExecutions(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 8}) + module := loadModule(t, "hostbytesget") + + var wg sync.WaitGroup + for i := range 32 { + wg.Add(1) + go func() { + defer wg.Done() + + // Every execution gets its own host, address and payload, so a + // result that belongs to another run is visible immediately. + seed := byte(i + 1) + payload := bytes.Repeat([]byte{seed}, 64) + host := newMockHost() + addr := host.addData(seed, payload) + + res, err := s.Execute(t.Context(), compute.Request{ + Module: module, + Input: append(addr.Bytes(), u32(4096)...), + Host: host, + // One call and one payload each: a budget shared between + // executions would starve most of them. + Limits: compute.Limits{MaxHostCalls: 1, MaxHostBytes: 64}, + }) + if err != nil { + // The service refuses rather than queues when every worker is + // taken, which is the point of the semaphore, not a failure. + if !errors.Is(err, compute.ErrBusy) { + t.Errorf("execute: %v", err) + } + return + } + if res.Status != compute.StatusOK { + t.Errorf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + return + } + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != errnoOK { + t.Errorf("errno: got %d, want %d", fields[0], errnoOK) + return + } + if !bytes.Equal(data, payload) { + t.Errorf("payload for seed %d: got %q", seed, data) + } + }() + } + wg.Wait() +} diff --git a/pkg/compute/limits.go b/pkg/compute/limits.go index 7ba604dcddc..93badd50e30 100644 --- a/pkg/compute/limits.go +++ b/pkg/compute/limits.go @@ -7,14 +7,60 @@ package compute // Limits bound a single execution. They are supplied per request (clamped by the // API layer to the operator-configured maxima) and passed to the engine. type Limits struct { - // Fuel is the deterministic gas budget (instruction count). Zero means the - // engine default. Not enforced by the phase-0 wazero engine. - Fuel uint64 // Memory is the maximum linear memory in bytes the module may allocate. Memory uint64 // Entrypoint is the exported function to invoke. Empty selects the module's // WASI command entry (`_start`). Entrypoint string + // MaxHostCalls bounds how many swarm host calls one execution tree may + // make. Zero means defaultMaxHostCalls. + MaxHostCalls uint32 + // MaxHostBytes bounds the total payload, in both directions, that the + // swarm host calls of one execution tree may move. Zero means + // defaultMaxHostBytes. + MaxHostBytes uint64 + // MaxDepth bounds the number of execution levels a swarm_execute call tree + // may reach, the outermost execution included, so 1 permits no nesting at + // all. Zero means defaultMaxDepth. + MaxDepth uint32 +} + +// Defaults applied when a limit is left unset. They are deliberately modest: +// this engine has no work-based bound, so the host budgets are what stop a +// module from making the node fetch or store without end. +const ( + defaultMaxHostCalls uint32 = 64 + defaultMaxHostBytes uint64 = 32 << 20 + defaultMaxDepth uint32 = 4 +) + +// HostCalls, HostBytes and Depth resolve a limit against its default. They are +// exported because a Host has to agree with the engine on the numbers: it +// refuses oversized work before materialising it, which it can only do if it +// sees the same effective limit the budget was built from. + +// HostCalls is the effective host-call limit. +func (l Limits) HostCalls() uint32 { + if l.MaxHostCalls == 0 { + return defaultMaxHostCalls + } + return l.MaxHostCalls +} + +// HostBytes is the effective host-byte limit. +func (l Limits) HostBytes() uint64 { + if l.MaxHostBytes == 0 { + return defaultMaxHostBytes + } + return l.MaxHostBytes +} + +// Depth is the effective limit on execution levels, the outermost included. +func (l Limits) Depth() uint32 { + if l.MaxDepth == 0 { + return defaultMaxDepth + } + return l.MaxDepth } const ( diff --git a/pkg/compute/testdata/README.md b/pkg/compute/testdata/README.md index e50760a42f2..59d7e495695 100644 --- a/pkg/compute/testdata/README.md +++ b/pkg/compute/testdata/README.md @@ -2,10 +2,37 @@ Each `*.wasm` module in this directory has its WebAssembly text source next to it as `*.wat`. The `.wat` file is the source of record; regenerate a module after -editing it with: +editing it with either assembler: + wasm-tools parse .wat -o .wasm wat2wasm .wat -o .wasm The modules are deliberately tiny and hand-written so the sandbox behaviour they -exercise (output, traps, exits, rejected imports, memory limits, non-termination, -request metadata) stays obvious. +exercise stays obvious. + +## Sandbox fixtures + +Output, traps, exits, rejected imports, memory limits, non-termination and +request metadata: `writer`, `echo`, `entrypoint`, `exit1`, `trap`, `infloop`, +`bigmem`, `badimport`, `method`. + +## Host fixtures + +These import the `swarm` module and exercise the guest ABI (see +`../README.md`). They read their arguments from stdin and write fixed-width +little-endian fields to stdout so a test can assert the exact result code a +module observes: + +| Fixture | stdin | stdout | +|---|---|---| +| `hostbytesget` | `[32-byte address][4-byte buffer length]` | `[errno][required length][payload]` | +| `hostbytesput` | `[32-byte batch id][payload]` | `[errno][32-byte reference]` | +| `hostchunk` | `[32-byte batch id][chunk data]` | `[put errno][get errno][retrieved chunk]` | +| `hostnested` | `[32-byte module address][nested input]` | `[errno][output length][nested output]` | +| `hostcalls` | `[32-byte address]` | `[successful calls][errno that stopped the loop]` | +| `hostbadptr` | — | `[errno]` | +| `hostputtrap` | `[32-byte batch id][payload]` | `[errno][32-byte reference]`, then traps | +| `hostunknown` | — | — (imports a function the host module does not define) | + +Each fixture writes its payload field only when the call succeeded, so a +non-zero result code yields the fixed-width fields alone. diff --git a/pkg/compute/testdata/hostbadptr.wasm b/pkg/compute/testdata/hostbadptr.wasm new file mode 100644 index 0000000000000000000000000000000000000000..878e6d071cae4d071f35e7c533630118063afcea GIT binary patch literal 196 zcmX|*JqyAx6h-fQ{jgfmg3v(}ij%90i&K6{8Zl~zv?U3ag3$hSi$gD*yPgB(3<01u zm*Fsth7#Z#=bdtubhT>TyYaHM`cvudk~|->vz6CC)dmvzc z$Fb3-u{|S}&MWJ|s}mIvg&4=L7+H*kIA?Ta2olM}A;UgRHw2NSNil_m6F7#oGMaM6 J?5E|d`~h)oF9`qu literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/hostbadptr.wat b/pkg/compute/testdata/hostbadptr.wat new file mode 100644 index 00000000000..9f4007c7e52 --- /dev/null +++ b/pkg/compute/testdata/hostbadptr.wat @@ -0,0 +1,15 @@ +;; Passes an address pointer far outside linear memory. The host bounds-checks +;; every offset, so this must come back INVALID (5) rather than trapping. +;; +;; stdout: [4-byte errno] +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_bytes_get" + (func $bytes_get (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 8) "\20\00\00\00\04\00\00\00") + (func (export "_start") + (i32.store (i32.const 32) + (call $bytes_get (i32.const 0xffff0000) (i32.const 256) (i32.const 4096) (i32.const 40))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 28))))) diff --git a/pkg/compute/testdata/hostbytesget.wasm b/pkg/compute/testdata/hostbytesget.wasm new file mode 100644 index 0000000000000000000000000000000000000000..31c04e0bd359af2cc24bc5828fa0119c5d2600a1 GIT binary patch literal 286 zcmZ`!K}rKb5UlRmB&#d9phyU!dsxJ)7th-Pe<4gpGU^^C2{VIBPVs3z#i!USn48^H zchMA06^8jg(QLhYYus=>eXfmnwg&zYWFi=R z6CIF-!TR>}ht1P2x?nag0*Z-JMWE>!Pg>XZe#e-Y5Ur0;#}9N*G-^nbJ}zHP zKcM%l_oZGHe8bSol@M-cREqHR3c4&IciG-QaW_Mr%YP=gC}@%Aa}sr4R1&uQMJ5wf j(%IJ0rNUn}I%MFZgKeJBx8+V1q!>xvcaBoKu}${{IUPyD literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/hostcalls.wat b/pkg/compute/testdata/hostcalls.wat new file mode 100644 index 00000000000..2ac2e5eb471 --- /dev/null +++ b/pkg/compute/testdata/hostcalls.wat @@ -0,0 +1,30 @@ +;; Fetches the same address over and over until a host call is refused, so a +;; test can see exactly where the call budget cuts the module off. +;; +;; stdin: [32-byte address] +;; stdout: [4-byte successful call count][4-byte errno that stopped the loop] +;; +;; memory map: 0 read iovec, 8 write iovec, 24 nread, 28 nwritten, 32 count, +;; 36 errno, 40 out_len, 64 address, 256 payload buffer. +(module + (import "wasi_snapshot_preview1" "fd_read" + (func $fd_read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_bytes_get" + (func $bytes_get (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\40\00\00\00\20\00\00\00\20\00\00\00\08\00\00\00") + (func (export "_start") + (local $n i32) + (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24))) + (block $done + (loop $again + (i32.store (i32.const 36) + (call $bytes_get (i32.const 64) (i32.const 256) (i32.const 4096) (i32.const 40))) + (br_if $done (i32.ne (i32.load (i32.const 36)) (i32.const 0))) + (local.set $n (i32.add (local.get $n) (i32.const 1))) + (i32.store (i32.const 32) (local.get $n)) + ;; a safety stop, so a budget that never bites cannot hang the test + (br_if $again (i32.lt_u (local.get $n) (i32.const 10000))))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 28))))) diff --git a/pkg/compute/testdata/hostchunk.wasm b/pkg/compute/testdata/hostchunk.wasm new file mode 100644 index 0000000000000000000000000000000000000000..d0edefcdc81fa0b92ccef4e60518834ebd3d3ae3 GIT binary patch literal 352 zcmZ`#!D_=W43!+W_2$6>8zq!6a_AxK*2~V-Vc+4J)m=kz8fAr%xBFz;;67{DEhd|_3C^wo{#j7G(1kiV zfKlvh@b{-~-nWNlXWB!$%6~ZKGl4kD5ODG!#@_jT_!2NSG1-tn-dZ$3Ln0M9%&4SZ z(T}2{5wy3}HDmj)iQjclaXV?+@H0#~m*48+4XxGvP)$OwI(toV{^LBiy+Xjt*Gphccvlbp&+Bx1Ij3PvDSW0R

>WCPw)H literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/hostnested.wat b/pkg/compute/testdata/hostnested.wat new file mode 100644 index 00000000000..4317f417cb8 --- /dev/null +++ b/pkg/compute/testdata/hostnested.wat @@ -0,0 +1,26 @@ +;; Runs another module with swarm_execute and forwards its output. +;; +;; stdin: [32-byte module address][input for the nested module] +;; stdout: [4-byte errno][4-byte nested output length][nested output] +;; +;; memory map: 0 read iovec, 8/16 write iovecs, 24 nread, 28 nwritten, +;; 32 errno, 36 out_len, 1024 module address, 1056 nested input, +;; 16384 nested output buffer. +(module + (import "wasi_snapshot_preview1" "fd_read" + (func $fd_read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_execute" + (func $execute (param i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\00\04\00\00\00\20\00\00\20\00\00\00\08\00\00\00\00\40\00\00\00\00\00\00") + (func (export "_start") + (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24))) + (i32.store (i32.const 32) + (call $execute (i32.const 1024) (i32.const 1056) + (i32.sub (i32.load (i32.const 24)) (i32.const 32)) + (i32.const 16384) (i32.const 8192) (i32.const 36))) + (if (i32.eqz (i32.load (i32.const 32))) + (then (i32.store (i32.const 20) (i32.load (i32.const 36))))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28))))) diff --git a/pkg/compute/testdata/hostputtrap.wasm b/pkg/compute/testdata/hostputtrap.wasm new file mode 100644 index 0000000000000000000000000000000000000000..568d76872da24a1ca2e6c09eb35a6313924eebb2 GIT binary patch literal 289 zcmZ`!!Ab)`41LK=+tn3ZP-H1Z_at7ucy18!Cxq!pN4m(g%gpG~Q~N1?sAPTKkG GB=;9-xk7LN literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/hostputtrap.wat b/pkg/compute/testdata/hostputtrap.wat new file mode 100644 index 00000000000..c71aec73c9f --- /dev/null +++ b/pkg/compute/testdata/hostputtrap.wat @@ -0,0 +1,25 @@ +;; Uploads the payload from stdin and then traps, so a test can check that a +;; failed run leaves nothing behind: the chunks it wrote must be dropped, not +;; handed to the pusher. +;; +;; stdin: [32-byte batch id][payload] +;; stdout: [4-byte errno][32-byte reference] — written before the trap, so the +;; reference is visible even though the run failed. +(module + (import "wasi_snapshot_preview1" "fd_read" + (func $fd_read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_bytes_put" + (func $bytes_put (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "\00\04\00\00\00\40\00\00\20\00\00\00\04\00\00\00\00\02\00\00\20\00\00\00") + (func (export "_start") + (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24))) + (i32.store (i32.const 32) + (call $bytes_put (i32.const 1024) (i32.const 1056) + (i32.sub (i32.load (i32.const 24)) (i32.const 32)) (i32.const 512))) + (if (i32.ne (i32.load (i32.const 32)) (i32.const 0)) + (then (i32.store (i32.const 20) (i32.const 0)))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28))) + (unreachable))) diff --git a/pkg/compute/testdata/hostunknown.wasm b/pkg/compute/testdata/hostunknown.wasm new file mode 100644 index 0000000000000000000000000000000000000000..7d7a76b0f20f29b7642446eb3c4cab6ec5a27c3d GIT binary patch literal 98 zcmW;Du?m176a~er)|vmL~$1i(%LA3gz)g|=R?X#eZ? nd?dgKX$?U)ORGt9xdpASXDlb!cXZe=VI43wd(=cnG+%uIxg-+* literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/hostunknown.wat b/pkg/compute/testdata/hostunknown.wat new file mode 100644 index 00000000000..7e4f5358413 --- /dev/null +++ b/pkg/compute/testdata/hostunknown.wat @@ -0,0 +1,7 @@ +;; Imports a function the swarm host module does not define. Imports are checked +;; before instantiation, so this is StatusInvalidModule and never a link trap. +(module + (import "swarm" "swarm_nope" (func $nope (param i32) (result i32))) + (memory (export "memory") 1) + (func (export "_start") + (drop (call $nope (i32.const 0))))) diff --git a/pkg/compute/wazero.go b/pkg/compute/wazero.go index aa5b4b1a189..5fd4e79566b 100644 --- a/pkg/compute/wazero.go +++ b/pkg/compute/wazero.go @@ -28,12 +28,12 @@ const ( envRequestMethod = "REQUEST_METHOD" ) -// wazeroEngine is the phase-0, in-process execution engine. +// wazeroEngine is the in-process execution engine. // -// WARNING: it does NOT meter execution deterministically and it wires WASI -// stdin/stdout for I/O, so its output is not guaranteed reproducible across -// nodes. It exists to exercise the download/API/wiring path and to be swapped -// out for the deterministic out-of-process wasmtime worker. +// WARNING: it does NOT meter execution deterministically, it wires WASI +// stdin/stdout for I/O, and a module reaching the node through the swarm host +// module observes node-local state. Its output is not reproducible across +// nodes. See the package documentation. type wazeroEngine struct { logger log.Logger } @@ -46,7 +46,14 @@ func newWazeroEngine(logger log.Logger) *wazeroEngine { // whatever the module writes to stdout as the result. A fresh runtime is created // per call so no state leaks between executions. func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) { - e.logger.Debug("execute: starting", "module_size", len(req.Module), "input_size", len(req.Input), "method", req.Method, "entrypoint", req.Limits.Entrypoint, "memory_limit", req.Limits.Memory) + return e.execute(ctx, req, newBudget(req.Limits), 0) +} + +// execute runs one module at the given nesting depth. The budget is shared by +// pointer across the whole call tree, so a module cannot multiply its host-call +// allowance by recursing through swarm_execute. +func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, depth uint32) (Result, error) { + e.logger.Debug("execute: starting", "module_size", len(req.Module), "input_size", len(req.Input), "method", req.Method, "entrypoint", req.Limits.Entrypoint, "memory_limit", req.Limits.Memory, "depth", depth) cfg := wazero.NewRuntimeConfig(). // Interrupt execution when the context (watchdog) is cancelled. @@ -62,6 +69,31 @@ func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) return Result{Status: StatusHostError, TrapMessage: err.Error()}, err } + // The swarm module is built per execution, closing over this tree's budget + // and depth. Nothing is shared between untrusted programs. + var hs *hostState + if req.Host != nil { + hs = &hostState{ + host: req.Host, + budget: b, + depth: depth, + maxDepth: req.Limits.Depth(), + logger: e.logger, + } + hs.nested = func(ctx context.Context, module, input []byte) (Result, error) { + nested := req + nested.Module = module + nested.Input = input + // A nested module is always run as a WASI command: the caller's + // entrypoint header describes the outermost module only. + nested.Limits.Entrypoint = "" + return e.execute(ctx, nested, b, depth+1) + } + if err := buildSwarmModule(ctx, r, hs); err != nil { + return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + } + } + compiled, err := r.CompileModule(ctx, req.Module) if err != nil { e.logger.Debug("execute: compile failed", "error", err) @@ -72,7 +104,7 @@ func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) // Reject anything the sandbox does not provide up front, so an unsatisfiable // import is a deterministic verdict on the module rather than a link failure // surfacing as a trap. - if err := checkImports(compiled); err != nil { + if err := checkImports(compiled, hs != nil); err != nil { e.logger.Debug("execute: rejected import", "error", err) return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil } @@ -106,9 +138,14 @@ func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) modCfg = modCfg.WithStartFunctions() } + // For a WASI command module `_start` runs during instantiation, so a host + // call — and a host abort — can happen here. mod, err := r.InstantiateModule(ctx, compiled, modCfg) if err != nil { e.logger.Debug("execute: instantiate failed", "error", err) + if res, aborted := hostAbort(hs); aborted { + return res, hs.err + } if res, ok := classifyRunError(err, stdout.Bytes()); ok { e.logger.Debug("execute: instantiate error classified", "status", res.Status) return res, nil @@ -125,6 +162,9 @@ func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) } if _, err := fn.Call(ctx); err != nil { e.logger.Debug("execute: entrypoint call failed", "entrypoint", req.Limits.Entrypoint, "error", err) + if res, aborted := hostAbort(hs); aborted { + return res, hs.err + } if res, ok := classifyRunError(err, stdout.Bytes()); ok { e.logger.Debug("execute: entrypoint error classified", "status", res.Status) return res, nil @@ -133,19 +173,50 @@ func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) } } + // Defence in depth: a recorded host failure outranks an apparently clean run. + if res, aborted := hostAbort(hs); aborted { + return res, hs.err + } + e.logger.Debug("execute: ok", "output_size", stdout.Len()) return Result{Status: StatusOK, Output: stdout.Bytes()}, nil } +// hostAbort reports whether the run was torn down by a node-local host failure +// and, if so, the verdict to return. A host abort unwinds the guest as a trap, +// but a trap is a verdict on the program and this was not one: it must surface +// as StatusHostError with a non-nil error, never as StatusTrap. +func hostAbort(hs *hostState) (Result, bool) { + if hs == nil || hs.err == nil { + return Result{}, false + } + return Result{Status: StatusHostError, TrapMessage: hs.err.Error()}, true +} + // checkImports verifies the module only imports from the host environment the // sandbox instantiates. Importing memory is not supported at all. -func checkImports(compiled wazero.CompiledModule) error { +// +// Rejecting up front means an unsatisfiable import is a deterministic verdict on +// the module (StatusInvalidModule) rather than a link failure surfacing as a +// trap. The WASI namespace is accepted wholesale — wasi_snapshot_preview1 +// provides all of preview1 — while the swarm namespace is checked name by name +// against what buildSwarmModule actually defines. +func checkImports(compiled wazero.CompiledModule, hostAvailable bool) error { for _, f := range compiled.ImportedFunctions() { moduleName, name, ok := f.Import() if !ok { continue } - if moduleName != wasiModuleName { + switch moduleName { + case wasiModuleName: + case swarmModuleName: + if !hostAvailable { + return fmt.Errorf("import %q from module %q: node access is not available", name, moduleName) + } + if _, ok := swarmExports[name]; !ok { + return fmt.Errorf("unknown import %q from module %q", name, moduleName) + } + default: return fmt.Errorf("unsupported import %q from module %q", name, moduleName) } } diff --git a/pkg/node/node.go b/pkg/node/node.go index 01bb3860f97..fe68162d5ba 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -208,10 +208,14 @@ type Options struct { WasmWorkers int WasmExecTimeout time.Duration WasmMaxModuleSize uint64 - WasmFuel uint64 - WasmMaxFuel uint64 WasmMemory uint64 WasmMaxMemory uint64 + WasmHostCalls uint64 + WasmMaxHostCalls uint64 + WasmHostBytes uint64 + WasmMaxHostBytes uint64 + WasmExecDepth uint64 + WasmMaxExecDepth uint64 } const ( @@ -1389,10 +1393,15 @@ func NewBee( Compute: computeService, ExecuteConfig: api.ExecuteConfig{ MaxModuleSize: o.WasmMaxModuleSize, - DefaultFuel: o.WasmFuel, - MaxFuel: o.WasmMaxFuel, DefaultMemory: o.WasmMemory, MaxMemory: o.WasmMaxMemory, + + DefaultHostCalls: o.WasmHostCalls, + MaxHostCalls: o.WasmMaxHostCalls, + DefaultHostBytes: o.WasmHostBytes, + MaxHostBytes: o.WasmMaxHostBytes, + DefaultDepth: o.WasmExecDepth, + MaxDepth: o.WasmMaxExecDepth, }, } From 2c01d23762cb4c7cb7c4c6e328f7dbcff31bc340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Sun, 30 Aug 2026 22:23:54 +0200 Subject: [PATCH 3/3] feat: exposing request/response in engine --- cmd/bee/cmd/cmd.go | 11 + cmd/bee/cmd/start.go | 4 + openapi/Swarm.yaml | 64 ++++- openapi/SwarmCommon.yaml | 16 ++ pkg/api/execute.go | 216 ++++++++++++++--- pkg/api/execute_env.go | 173 ++++++++++++++ pkg/api/execute_env_test.go | 182 +++++++++++++++ pkg/api/execute_integration_test.go | 127 ++++++++++ pkg/api/execute_response_test.go | 276 ++++++++++++++++++++++ pkg/api/host.go | 28 ++- pkg/api/host_test.go | 15 +- pkg/api/router.go | 12 +- pkg/compute/README.md | 111 ++++++++- pkg/compute/engine.go | 49 ++++ pkg/compute/export_test.go | 52 ++++- pkg/compute/host.go | 237 ++++++++++++++++++- pkg/compute/host_test.go | 53 +++-- pkg/compute/limits.go | 48 +++- pkg/compute/response_test.go | 346 ++++++++++++++++++++++++++++ pkg/compute/testdata/README.md | 14 ++ pkg/compute/testdata/method.wat | 7 +- pkg/compute/testdata/respbad.wasm | Bin 0 -> 457 bytes pkg/compute/testdata/respbad.wat | 47 ++++ pkg/compute/testdata/respcode.wasm | Bin 0 -> 220 bytes pkg/compute/testdata/respcode.wat | 20 ++ pkg/compute/testdata/respflood.wasm | Bin 0 -> 301 bytes pkg/compute/testdata/respflood.wat | 31 +++ pkg/compute/testdata/respok.wasm | Bin 0 -> 372 bytes pkg/compute/testdata/respok.wat | 32 +++ pkg/compute/testdata/resptrap.wasm | Bin 0 -> 286 bytes pkg/compute/testdata/resptrap.wat | 22 ++ pkg/compute/wazero.go | 71 ++++-- pkg/node/node.go | 15 ++ 33 files changed, 2163 insertions(+), 116 deletions(-) create mode 100644 pkg/api/execute_env.go create mode 100644 pkg/api/execute_env_test.go create mode 100644 pkg/api/execute_integration_test.go create mode 100644 pkg/api/execute_response_test.go create mode 100644 pkg/compute/response_test.go create mode 100644 pkg/compute/testdata/respbad.wasm create mode 100644 pkg/compute/testdata/respbad.wat create mode 100644 pkg/compute/testdata/respcode.wasm create mode 100644 pkg/compute/testdata/respcode.wat create mode 100644 pkg/compute/testdata/respflood.wasm create mode 100644 pkg/compute/testdata/respflood.wat create mode 100644 pkg/compute/testdata/respok.wasm create mode 100644 pkg/compute/testdata/respok.wat create mode 100644 pkg/compute/testdata/resptrap.wasm create mode 100644 pkg/compute/testdata/resptrap.wat diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index 98bf0d3c398..cf7ba16b0bf 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -98,6 +98,10 @@ const ( optionNameWasmMaxHostBytes = "wasm-max-host-bytes" optionNameWasmExecDepth = "wasm-exec-depth" optionNameWasmMaxExecDepth = "wasm-max-exec-depth" + optionNameWasmMaxResponseHeaders = "wasm-max-response-headers" + optionNameWasmMaxResponseHeaderBytes = "wasm-max-response-header-bytes" + optionNameWasmRequestHeaders = "wasm-request-headers" + optionNameWasmMaxEnvBytes = "wasm-max-env-bytes" optionP2PWSSAddr = "p2p-wss-addr" optionNATWSSAddr = "nat-wss-addr" optionAutoTLSDomain = "autotls-domain" @@ -360,6 +364,13 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().Uint64(optionNameWasmMaxHostBytes, 256*1024*1024, "maximum total bytes moved by swarm host calls a request may ask for") cmd.Flags().Uint64(optionNameWasmExecDepth, 4, "default maximum nesting depth of swarm_execute calls") cmd.Flags().Uint64(optionNameWasmMaxExecDepth, 8, "maximum nesting depth of swarm_execute calls a request may ask for") + // No default/maximum pair for these: the request-header overrides exist so a + // caller can bound risk it is exposed to, and a caller is not exposed to the + // response header budget. + cmd.Flags().Uint64(optionNameWasmMaxResponseHeaders, 32, "maximum number of response headers a WASM module may set") + cmd.Flags().Uint64(optionNameWasmMaxResponseHeaderBytes, 8*1024, "maximum total size in bytes of the response headers a WASM module may set") + cmd.Flags().StringSlice(optionNameWasmRequestHeaders, nil, "request headers exposed to a WASM module as CGI HTTP_* variables; replaces the built-in list. Authorization, Proxy-Authorization and Cookie are never exposed") + cmd.Flags().Uint64(optionNameWasmMaxEnvBytes, 16*1024, "maximum total size in bytes of the request metadata exposed to a WASM module") cmd.Flags().String(optionP2PWSSAddr, ":1635", "p2p wss address") cmd.Flags().String(optionNATWSSAddr, "", "WSS NAT exposed address") cmd.Flags().String(optionAutoTLSDomain, p2pforge.DefaultForgeDomain, "autotls domain") diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index 2dd1e2d82e7..b399da4c675 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -375,6 +375,10 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo WasmMaxHostBytes: c.config.GetUint64(optionNameWasmMaxHostBytes), WasmExecDepth: c.config.GetUint64(optionNameWasmExecDepth), WasmMaxExecDepth: c.config.GetUint64(optionNameWasmMaxExecDepth), + WasmMaxResponseHeaders: c.config.GetUint64(optionNameWasmMaxResponseHeaders), + WasmMaxResponseHeaderBytes: c.config.GetUint64(optionNameWasmMaxResponseHeaderBytes), + WasmRequestHeaders: c.config.GetStringSlice(optionNameWasmRequestHeaders), + WasmMaxEnvBytes: c.config.GetUint64(optionNameWasmMaxEnvBytes), }) return b, err diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index d86becc2778..059c988970a 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -234,16 +234,28 @@ paths: Every HTTP method is accepted, including ones not listed here, and the - module decides how to react to it: the method is handed to the module as - the `REQUEST_METHOD` environment variable, following CGI convention. The - one exception is `OPTIONS`, which the node answers itself as a CORS - preflight so it never reaches untrusted code. + module decides how to react to it. The one exception is `OPTIONS`, which + the node answers itself as a CORS preflight so it never reaches + untrusted code. + + + **Request metadata** reaches the module CGI-style, through the + environment: `REQUEST_METHOD`, `SCRIPT_NAME`, `PATH_INFO`, + `QUERY_STRING`, `REQUEST_URI`, `CONTENT_TYPE`, `CONTENT_LENGTH` and the + allowlisted request headers as `HTTP_*`. A trailing path is available at + `/@/{address}/{path}` and arrives as `PATH_INFO`, which is empty on the + bare form. The host environment is never inherited, and + `Authorization`, `Proxy-Authorization` and `Cookie` are never forwarded. + The environment is capped; overflow is a 431. The representation of the result is negotiated with the `Accept` header: - `application/json` (the default) returns the full execution envelope, + `application/json` returns the full execution envelope, `application/octet-stream` and `text/html` return the raw output. Any - other media type is rejected with 406. + other media type is rejected with 406. A wildcard `Accept` returns the + envelope unless the module set response metadata of its own, in which + case it returns the raw output with the module's content type — which is + what lets a browser load a module's stylesheets and images. A verdict on the program itself (`trap`, `invalid-module`) is reported @@ -260,6 +272,17 @@ paths: rejected before the module runs. + **Shaping the response.** The same host module offers + `swarm_response_status` and `swarm_response_header`, which let a module + set the HTTP status and headers rather than having them derived from the + verdict and the caller's `Accept`. These need no node access and are + available even when it is switched off. A module cannot set + `Swarm-Wasm-*`, `Access-Control-*`, `Set-Cookie`, the origin-wide + security headers or the hop-by-hop headers. Response metadata is + committed only on a clean run: a module that traps sets no headers, just + as it stores nothing. + + Uploads are paid for by the postage batch the module passes to a put call, which it can only have received as input; the node resolves it exactly as `POST /chunks` does, so this path grants no authority the @@ -325,6 +348,35 @@ paths: patch: *executeOperation delete: *executeOperation + "/@/{address}/{path}": + parameters: + - in: path + name: address + schema: + $ref: "SwarmCommon.yaml#/components/schemas/SwarmReference" + required: true + description: Swarm address reference of the WASM module + - in: path + name: path + schema: + type: string + required: true + allowEmptyValue: true + description: > + Path handed to the module as `PATH_INFO`, prefixed with `/`. May be + empty, which is how `/@/{address}/` differs from `/@/{address}`. + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmMemoryLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmEntrypoint" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostCallsLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostBytesLimit" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmDepthLimit" + get: *executeOperation + head: *executeOperation + post: *executeOperation + put: *executeOperation + patch: *executeOperation + delete: *executeOperation + "/bytes/{address}": get: summary: "Retrieve data by reference" diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ce34674ad4f..9a20ef61aad 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -680,6 +680,22 @@ components: trapMessage: type: string description: Explanation of a non-`ok` status + httpStatus: + type: integer + description: > + HTTP status the module asked for through `swarm_response_status`, + absent when it asked for none. In this representation it is reported + rather than applied, so that a client can tell the module's own 404 + from the node's. + headers: + type: object + additionalProperties: + type: array + items: + type: string + description: > + Response headers the module set through `swarm_response_header`, + grouped by name. Reported rather than applied, as with `httpStatus`. PublicKey: type: string diff --git a/pkg/api/execute.go b/pkg/api/execute.go index 939fa2d8e26..07df965ac73 100644 --- a/pkg/api/execute.go +++ b/pkg/api/execute.go @@ -6,6 +6,8 @@ package api import ( "errors" + "fmt" + "html" "io" "net/http" "strings" @@ -36,13 +38,64 @@ type ExecuteConfig struct { MaxHostBytes uint64 DefaultDepth uint64 MaxDepth uint64 + // Bounds on the response metadata a module may set. These carry no + // default/maximum pair and no per-request header: that pattern exists so a + // caller can lower risk it is exposed to, and a caller is not exposed to + // this one. + MaxResponseHeaders uint64 + MaxResponseHeaderBytes uint64 + // RequestHeaders names the request headers forwarded to a module as CGI + // HTTP_* variables. Empty means defaultRequestHeaders; a non-empty value + // replaces that list outright, so an operator who widens the surface owns + // the decision. forbiddenRequestHeaders is enforced regardless. + RequestHeaders []string + // MaxEnvBytes bounds the derived environment. Zero means + // defaultMaxEnvBytes. + MaxEnvBytes uint64 } +// defaultMaxEnvBytes bounds the CGI environment when the operator has not set a +// limit. Overflow is a 431 rather than a truncation: silently shortening the +// environment would hand the guest a lie it cannot detect. +const defaultMaxEnvBytes = 16 << 10 + // executeResponse is the structured (JSON) representation of an execution result. type executeResponse struct { Status string `json:"status"` Output []byte `json:"output"` TrapMessage string `json:"trapMessage,omitempty"` + // HTTPStatus and Headers report what the module asked for through + // swarm_response_*. In this representation they are reported, never applied: + // a client that asked for the envelope asked to be told about the run, not to + // have its own transport reshaped by it. Applying them would also make "the + // module says 404" indistinguishable from "the node says module not found". + HTTPStatus int `json:"httpStatus,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` +} + +// envelopeFor renders the result as the JSON envelope both a 200 and a +// program-fault 400 carry. +func envelopeFor(res compute.Result) executeResponse { + return executeResponse{ + Status: res.Status.String(), + Output: res.Output, + TrapMessage: res.TrapMessage, + HTTPStatus: res.Response.Status, + Headers: headerMap(res.Response), + } +} + +// headerMap groups the guest's headers by name, preserving the order of repeats. +func headerMap(meta compute.ResponseMeta) map[string][]string { + if len(meta.Headers) == 0 { + return nil + } + out := make(map[string][]string, len(meta.Headers)) + for _, h := range meta.Headers { + name := http.CanonicalHeaderKey(h.Name) + out[name] = append(out[name], h.Value) + } + return out } // executeHandler downloads the WASM module addressed by {address}, runs it in the @@ -62,14 +115,34 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { return } + vars := mux.Vars(r) + paths := struct { Address swarm.Address `map:"address,resolve" validate:"required"` }{} - if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + if response := s.mapStructure(vars, &paths); response != nil { response("invalid path params", logger, w) return } + // PATH_INFO is empty on the bare route and starts with "/" on the + // trailing-path one, which is the CGI rule: /@/a -> "", /@/a/ -> "/", + // /@/a/x/y -> "/x/y". mux hands back the decoded path, matching /bzz. + pathInfo := "" + if raw, ok := vars["path"]; ok { + pathInfo = "/" + raw + } + + env := s.executeEnv(r, pathInfo) + maxEnv := s.executeConfig.MaxEnvBytes + if maxEnv == 0 { + maxEnv = defaultMaxEnvBytes + } + if uint64(envSize(env)) > maxEnv { + jsonhttp.RequestHeaderFieldsTooLarge(w, "request metadata exceeds maximum size") + return + } + headers := struct { Memory *uint64 `map:"Swarm-Wasm-Memory-Limit"` Entrypoint string `map:"Swarm-Wasm-Entrypoint"` @@ -84,7 +157,7 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { // Negotiate the response representation up front so we can reject an // unsupported Accept before doing any work. - format, ok := negotiateExecuteFormat(r.Header.Get(AcceptHeader)) + format, explicitJSON, ok := negotiateExecuteFormat(r.Header.Get(AcceptHeader)) if !ok { jsonhttp.NotAcceptable(w, "unsupported Accept media type") return @@ -96,6 +169,9 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { MaxHostCalls: uint32(clampLimit(headers.HostCalls, s.executeConfig.DefaultHostCalls, s.executeConfig.MaxHostCalls)), MaxHostBytes: clampLimit(headers.HostBytes, s.executeConfig.DefaultHostBytes, s.executeConfig.MaxHostBytes), MaxDepth: uint32(clampLimit(headers.Depth, s.executeConfig.DefaultDepth, s.executeConfig.MaxDepth)), + + MaxResponseHeaders: uint32(s.executeConfig.MaxResponseHeaders), + MaxResponseHeaderBytes: uint32(s.executeConfig.MaxResponseHeaderBytes), } // Download and reassemble the module bytes, capped at the configured maximum. @@ -139,12 +215,13 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { // The host is per-request: any upload it opens belongs to this execution // alone and is committed or dropped below. - host := s.newExecuteHost(logger, lim.HostBytes()) + host := s.newExecuteHost(r.Context(), logger, lim.HostBytes()) result, err := s.compute.Execute(r.Context(), compute.Request{ Module: module, Method: r.Method, Input: input, + Env: env, Limits: lim, Host: host, }) @@ -171,87 +248,154 @@ func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) { return } + // A wildcard Accept defaults to the envelope, but a module that shaped its own + // response has expressed an opinion the wildcard has not contradicted. Honour + // it, so browsers and fetch() get the bytes the module meant to serve. A + // module that set nothing is untouched, which is what keeps this additive. + if format == formatJSON && !explicitJSON && !result.Response.Empty() { + format = formatModule + } + renderExecResult(w, format, result) } // renderExecResult writes the execution result in the negotiated representation. -// The HTTP status is derived from the program verdict and is independent of the -// chosen format. +// The HTTP status is the program verdict's unless the module set its own, which +// it can only do in the raw representations. func renderExecResult(w http.ResponseWriter, format string, res compute.Result) { + // Set first, so the guest's headers cannot end up overwriting it. The + // denylist forbids the name anyway; this is the second lock on that door. w.Header().Set(SwarmWasmStatusHeader, res.Status.String()) switch res.Status { case compute.StatusInvalidModule, compute.StatusTrap: - // Program's fault: deterministic bad request. - jsonhttp.BadRequest(w, execErrorBody(format, res)) + // Program's fault: deterministic bad request. res.Response is empty on + // these paths by construction, so nothing of the guest's is applied. + renderExecError(w, format, res) return case compute.StatusHostError: jsonhttp.InternalServerError(w, "execution failed") return } - // StatusOK: 200. + if format == formatJSON { + // Reported, not applied. + jsonhttp.OK(w, envelopeFor(res)) + return + } + + // A raw representation: the module owns the body, so it owns the headers + // describing it. Its Content-Type replaces the negotiated default. switch format { - case formatJSON: - jsonhttp.OK(w, executeResponse{ - Status: res.Status.String(), - Output: res.Output, - TrapMessage: res.TrapMessage, - }) case formatHTML: w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(res.Output) - default: // formatOctet + case formatOctet: + w.Header().Set(ContentTypeHeader, "application/octet-stream") + default: // formatModule: the guest decides, with a conservative fallback. w.Header().Set(ContentTypeHeader, "application/octet-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(res.Output) } + applyResponseHeaders(w, res.Response) + + status := http.StatusOK + if res.Response.Status != 0 { + status = res.Response.Status + } + w.WriteHeader(status) + _, _ = w.Write(res.Output) } -// execErrorBody builds the body for a deterministic program-fault response. For -// JSON it returns the structured envelope; otherwise a short message string. -func execErrorBody(format string, res compute.Result) interface{} { - if format == formatJSON { - return executeResponse{ - Status: res.Status.String(), - Output: res.Output, - TrapMessage: res.TrapMessage, +// applyResponseHeaders writes the guest's headers onto the response. +// +// The first occurrence of a name replaces whatever the node negotiated, and +// later repeats accumulate, so a guest can both override Content-Type and send +// several Link headers. The denylist is re-checked here: the engine already +// enforces it, and an engine bug should not become a header leak. +func applyResponseHeaders(w http.ResponseWriter, meta compute.ResponseMeta) { + seen := make(map[string]struct{}, len(meta.Headers)) + for _, h := range meta.Headers { + if compute.DeniedResponseHeader(h.Name) { + continue } + name := http.CanonicalHeaderKey(h.Name) + if _, ok := seen[name]; !ok { + w.Header().Del(name) + seen[name] = struct{}{} + } + w.Header().Add(name, h.Value) } +} + +// renderExecError writes a deterministic program-fault response in the +// representation the client negotiated. +// +// The JSON envelope is the same one a 200 carries. For the raw representations a +// client that asked for HTML gets HTML: answering a negotiated text/html request +// with a JSON body, as this used to, is a content-type lie. +func renderExecError(w http.ResponseWriter, format string, res compute.Result) { + if format == formatJSON { + jsonhttp.BadRequest(w, envelopeFor(res)) + return + } + msg := res.Status.String() if res.TrapMessage != "" { msg += ": " + res.TrapMessage } - return msg + + if format == formatHTML { + w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "execution failed

execution failed

%s

\n", html.EscapeString(msg)) + return + } + + w.Header().Set(ContentTypeHeader, "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, msg+"\n") } const ( formatOctet = "octet" formatJSON = "json" formatHTML = "html" + // formatModule is raw output whose Content-Type comes entirely from the + // module. It is never negotiated directly: it is what a wildcard Accept + // becomes once the module has said something about its own response. + formatModule = "module" ) // negotiateExecuteFormat picks a response representation from the Accept header. -// It returns false when the client requires a media type we do not support. -func negotiateExecuteFormat(accept string) (string, bool) { +// +// The second result reports whether the client named application/json outright, +// as opposed to reaching JSON through a wildcard. That distinction matters +// because a wildcard is not a request for the envelope, it is the absence of an +// opinion — and a browser fetching a subresource sends one. A stylesheet request +// is "Accept: text/css,*/*;q=0.1", an image request ends in "*/*;q=0.8", and a +// default fetch() sends "*/*", so without this a module could never serve +// anything but a top-level HTML page. +// +// The third result is false when the client requires a media type we cannot +// produce. +func negotiateExecuteFormat(accept string) (format string, explicit bool, ok bool) { accept = strings.TrimSpace(accept) if accept == "" { - return formatJSON, true + return formatJSON, false, true } for _, part := range strings.Split(accept, ",") { // Drop any parameters (e.g. q-values); we do not rank by quality. mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) switch mediaType { - case "application/json", "*/*", "application/*": - return formatJSON, true + case "application/json": + return formatJSON, true, true + case "*/*", "application/*": + return formatJSON, false, true case "text/html", "application/xhtml+xml": - return formatHTML, true + return formatHTML, false, true case "application/octet-stream": - return formatOctet, true + return formatOctet, false, true } } - return "", false + return "", false, false } // clampLimit resolves a per-request override against the configured default and diff --git a/pkg/api/execute_env.go b/pkg/api/execute_env.go new file mode 100644 index 00000000000..e13cf58fc18 --- /dev/null +++ b/pkg/api/execute_env.go @@ -0,0 +1,173 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/ethersphere/bee/v2/pkg/compute" +) + +// The request metadata a module sees, CGI-style. Only the method used to be +// exposed, which left a module with one URL and no way to tell one request from +// another. The names and their meanings are CGI's, because that is the +// convention REQUEST_METHOD already committed the ABI to. +const ( + envScriptName = "SCRIPT_NAME" + envPathInfo = "PATH_INFO" + envQueryString = "QUERY_STRING" + envRequestURI = "REQUEST_URI" + envContentType = "CONTENT_TYPE" + envContentLength = "CONTENT_LENGTH" + // envHeaderPrefix is CGI's prefix for a request header: Accept-Language + // becomes HTTP_ACCEPT_LANGUAGE. + envHeaderPrefix = "HTTP_" +) + +// defaultRequestHeaders are the request headers a module may see when the +// operator has not configured a list. +// +// This is an allowlist, deliberately unlike the response direction's denylist. A +// response header carries only what the guest already knows; a request header +// carries what the *operator's* clients send, so nothing gets through that is +// not named here. +// +// Absent on purpose: Authorization, Cookie and Proxy-Authorization are node +// credentials; Origin is the node's CORS business and would let a module +// fingerprint the pages embedding it; Accept-Encoding belongs to the node, which +// owns transfer encoding; X-Forwarded-For and Forwarded carry a visitor's IP, +// which a module could persist to Swarm permanently. +var defaultRequestHeaders = []string{ + "Accept", + "Accept-Language", + "Host", + "If-None-Match", + "If-Modified-Since", + "Range", + "Referer", + "User-Agent", + "X-Requested-With", + // Client-supplied and already accepted by the CORS layer. A module cannot + // enumerate the node's batches, so this is how a caller hands it one to + // upload with. + "Swarm-Postage-Batch-Id", +} + +// forbiddenRequestHeaders may never be forwarded, whatever an operator +// configures. Authorization together with the Access-Control-Allow-Credentials +// the node sets would mean handing an untrusted module the operator's token; an +// operator with a real need puts a proxy in front and passes a derived header. +var forbiddenRequestHeaders = map[string]struct{}{ + "authorization": {}, + "proxy-authorization": {}, + "cookie": {}, + "set-cookie": {}, +} + +// maxRequestHeaderValueLen is the longest header value forwarded to a module. +// A longer one is dropped rather than truncated: a truncated value is a lie the +// module cannot detect. +const maxRequestHeaderValueLen = 4 << 10 + +// ValidateRequestHeaders reports whether a configured request-header allowlist is +// safe to serve. It is checked at startup so a dangerous configuration stops the +// node rather than producing a warning nobody reads. +func ValidateRequestHeaders(names []string) error { + for _, name := range names { + if _, forbidden := forbiddenRequestHeaders[strings.ToLower(strings.TrimSpace(name))]; forbidden { + return fmt.Errorf("request header %q may not be exposed to WASM modules", name) + } + } + return nil +} + +// cgiHeaderName mangles a header name the way CGI does: upper case, dashes to +// underscores, prefixed with HTTP_. +func cgiHeaderName(name string) string { + return envHeaderPrefix + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) +} + +// executeEnv derives the CGI environment for one request. +// +// pathInfo is passed in rather than recomputed because only the router knows +// whether the trailing-path route matched: on the bare route PATH_INFO is empty, +// which is how a module distinguishes /@/{addr} from /@/{addr}/. +func (s *Service) executeEnv(r *http.Request, pathInfo string) []compute.EnvVar { + env := []compute.EnvVar{ + // SCRIPT_NAME is CGI's definition — the mount point, the request path + // minus PATH_INFO. Deriving it this way rather than rebuilding it from + // the address handles the /v1/@/... alias for free, and it is what a + // module needs to build links and redirects to itself. + {Name: envScriptName, Value: strings.TrimSuffix(r.URL.Path, pathInfo)}, + {Name: envPathInfo, Value: pathInfo}, + // Undecoded: a module that wants the decoded form decodes it, and one + // that needs the raw bytes still has them. + {Name: envQueryString, Value: r.URL.RawQuery}, + {Name: envRequestURI, Value: r.URL.RequestURI()}, + } + + // Per CGI these two describe the body and are not repeated as HTTP_*. + if ct := r.Header.Get(ContentTypeHeader); ct != "" { + env = append(env, compute.EnvVar{Name: envContentType, Value: ct}) + } + if r.ContentLength >= 0 { + env = append(env, compute.EnvVar{ + Name: envContentLength, + Value: strconv.FormatInt(r.ContentLength, 10), + }) + } + + allowed := s.executeConfig.RequestHeaders + if len(allowed) == 0 { + allowed = defaultRequestHeaders + } + + headers := make([]compute.EnvVar, 0, len(allowed)) + for _, name := range allowed { + if _, forbidden := forbiddenRequestHeaders[strings.ToLower(name)]; forbidden { + continue + } + values := r.Header.Values(name) + if len(values) == 0 { + continue + } + // Repeats join the way a single field-value would have been written. + value := strings.Join(values, ", ") + if len(value) > maxRequestHeaderValueLen { + continue + } + headers = append(headers, compute.EnvVar{Name: cgiHeaderName(name), Value: value}) + } + sort.Slice(headers, func(i, j int) bool { return headers[i].Name < headers[j].Name }) + + return append(env, sanitiseEnv(headers)...) +} + +// sanitiseEnv drops entries whose value carries a control character. Such a byte +// has no meaning in an environment block and a NUL would truncate it outright. +func sanitiseEnv(env []compute.EnvVar) []compute.EnvVar { + out := env[:0] + for _, v := range env { + if strings.ContainsFunc(v.Value, func(r rune) bool { return r < 0x20 || r == 0x7f }) { + continue + } + out = append(out, v) + } + return out +} + +// envSize is the number of bytes an environment block occupies, counting the +// "name=value\x00" framing WASI uses. +func envSize(env []compute.EnvVar) int { + total := 0 + for _, v := range env { + total += len(v.Name) + len(v.Value) + 2 + } + return total +} diff --git a/pkg/api/execute_env_test.go b/pkg/api/execute_env_test.go new file mode 100644 index 00000000000..706365da898 --- /dev/null +++ b/pkg/api/execute_env_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" +) + +// envOf runs a request and returns the environment the engine was handed. +func envOf(t *testing.T, engine *mockEngine) map[string]string { + t.Helper() + + env := make(map[string]string, len(engine.request.Env)) + for _, v := range engine.request.Env { + env[v.Name] = v.Value + } + return env +} + +func TestExecutePathInfo(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + suffix string + wantPathInfo string + wantScriptTail string + }{ + // CGI's rule: the bare form has no PATH_INFO at all, which is how a + // module tells /@/{addr} from /@/{addr}/ and can redirect between them. + {"bare address", "", "", ""}, + {"trailing slash", "/", "/", ""}, + {"single segment", "/style.css", "/style.css", ""}, + {"nested path", "/blob/abc/def", "/blob/abc/def", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+tc.suffix, + http.StatusOK, jsonhttptest.WithPutResponseBody(&body)) + + env := envOf(t, engine) + if got := env["PATH_INFO"]; got != tc.wantPathInfo { + t.Errorf("PATH_INFO: got %q, want %q", got, tc.wantPathInfo) + } + // SCRIPT_NAME is the mount point: the request path minus PATH_INFO. + wantScript := "/@/" + addr.String() + tc.wantScriptTail + if got := env["SCRIPT_NAME"]; got != wantScript { + t.Errorf("SCRIPT_NAME: got %q, want %q", got, wantScript) + } + // Concatenating them must reproduce the path the client asked for, + // which is the property a module relies on to build self-links. + if got := env["SCRIPT_NAME"] + env["PATH_INFO"]; got != "/@/"+addr.String()+tc.suffix { + t.Errorf("SCRIPT_NAME+PATH_INFO: got %q, want %q", got, "/@/"+addr.String()+tc.suffix) + } + }) + } +} + +func TestExecuteRequestEnv(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodPost, + "/@/"+addr.String()+"/upload?name=photo.png&size=3", http.StatusOK, + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "application/octet-stream"), + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"), + jsonhttptest.WithRequestHeader("User-Agent", "test-agent"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader("abc")), + jsonhttptest.WithPutResponseBody(&body), + ) + + env := envOf(t, engine) + for _, tc := range []struct{ name, want string }{ + {"QUERY_STRING", "name=photo.png&size=3"}, + {"REQUEST_URI", "/@/" + addr.String() + "/upload?name=photo.png&size=3"}, + {"CONTENT_TYPE", "application/octet-stream"}, + {"CONTENT_LENGTH", "3"}, + {"HTTP_USER_AGENT", "test-agent"}, + {"HTTP_ACCEPT", "application/json"}, + // The batch id is allowlisted on purpose: a module cannot enumerate the + // node's batches, so this is how a caller hands it one. + {"HTTP_SWARM_POSTAGE_BATCH_ID", batchOkStr}, + } { + if got := env[tc.name]; got != tc.want { + t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) + } + } +} + +// Forwarding request headers must never hand a module the operator's +// credentials, whatever the configuration says. +func TestExecuteRequestHeaderAllowlist(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + configure []string + }{ + {"default list", nil}, + {"operator tries to add them", []string{"Accept", "Authorization", "Cookie"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{ + RequestHeaders: tc.configure, + }) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusOK, + jsonhttptest.WithRequestHeader("Authorization", "Bearer operator-token"), + jsonhttptest.WithRequestHeader("Cookie", "session=secret"), + jsonhttptest.WithRequestHeader(api.AcceptHeader, "text/html"), + jsonhttptest.WithPutResponseBody(&body), + ) + + env := envOf(t, engine) + for _, name := range []string{"HTTP_AUTHORIZATION", "HTTP_COOKIE"} { + if got, ok := env[name]; ok { + t.Errorf("%s leaked to the guest: %q", name, got) + } + } + if got := env["HTTP_ACCEPT"]; got != "text/html" { + t.Errorf("HTTP_ACCEPT: got %q, want text/html", got) + } + }) + } +} + +// A configuration naming a credential header is a startup error, not a warning. +func TestValidateRequestHeaders(t *testing.T) { + t.Parallel() + + if err := api.ValidateRequestHeaders([]string{"Accept", "User-Agent"}); err != nil { + t.Errorf("a sane list was rejected: %v", err) + } + for _, name := range []string{"Authorization", "authorization", " Cookie ", "Proxy-Authorization"} { + if err := api.ValidateRequestHeaders([]string{"Accept", name}); err == nil { + t.Errorf("%q was accepted", name) + } + } +} + +// An oversized environment is refused outright rather than truncated: a +// truncated environment is a lie the module cannot detect. +func TestExecuteEnvTooLarge(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{MaxEnvBytes: 128}) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, + "/@/"+addr.String()+"/"+strings.Repeat("a", 512), http.StatusRequestHeaderFieldsTooLarge, + jsonhttptest.WithPutResponseBody(&body)) + + if engine.calls != 0 { + t.Errorf("engine ran despite an oversized environment") + } +} diff --git a/pkg/api/execute_integration_test.go b/pkg/api/execute_integration_test.go new file mode 100644 index 00000000000..767e664f934 --- /dev/null +++ b/pkg/api/execute_integration_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" + "github.com/ethersphere/bee/v2/pkg/log" +) + +// realEngineClient wires the actual wazero engine into the API test server, so +// this exercises the whole chain a request travels: HTTP handler -> negotiation +// -> engine -> swarm host module -> render. The mockEngine tests above pin the +// HTTP layer's behaviour; this pins that the layers agree. +func realEngineClient(t *testing.T, cfg api.ExecuteConfig) *http.Client { + t.Helper() + + engine, err := compute.New(compute.Options{ + Workers: 1, + Watchdog: 10 * time.Second, + Logger: log.Noop, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := engine.Close(); err != nil { + t.Errorf("close compute service: %v", err) + } + }) + return newExecuteTestServer(t, engine, cfg) +} + +// computeFixture loads a hand-written module from the compute package's +// testdata, which is where the fixtures and their .wat sources live. +func computeFixture(t *testing.T, name string) []byte { + t.Helper() + + module, err := os.ReadFile(filepath.Join("..", "compute", "testdata", name+".wasm")) + if err != nil { + t.Fatal(err) + } + return module +} + +// A real module setting a real Content-Type must reach a browser as that type, +// through the real negotiation path. This is the end-to-end form of the bug that +// motivated the format upgrade: before it, a stylesheet request came back as a +// base64 JSON envelope. +func TestExecuteEndToEndGuestHeaders(t *testing.T) { + t.Parallel() + + client := realEngineClient(t, api.ExecuteConfig{}) + addr := uploadModule(t, client, computeFixture(t, "respok")) + + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/style.css", + http.StatusCreated, // the module set 201 + jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptStylesheet), + jsonhttptest.WithExpectedResponse([]byte("hi")), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/css"), + jsonhttptest.WithExpectedResponseHeader("Cache-Control", "max-age=60"), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + ) +} + +// The same module through an explicit application/json must report rather than +// apply, and answer 200 because the envelope itself was delivered. +func TestExecuteEndToEndEnvelopeReports(t *testing.T) { + t.Parallel() + + client := realEngineClient(t, api.ExecuteConfig{}) + addr := uploadModule(t, client, computeFixture(t, "respok")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusOK, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"), + jsonhttptest.WithPutResponseBody(&body), + ) + for _, want := range []string{`"httpStatus":201`, `"Content-Type"`, `"text/css"`} { + if !bytes.Contains(body, []byte(want)) { + t.Errorf("envelope %s does not contain %s", body, want) + } + } +} + +// A real trapping module must not leave its headers or status behind. +func TestExecuteEndToEndTrapDropsMetadata(t *testing.T) { + t.Parallel() + + client := realEngineClient(t, api.ExecuteConfig{}) + addr := uploadModule(t, client, computeFixture(t, "resptrap")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", + // 400 from the verdict, not the 418 the module asked for. + http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptNavigation), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"), + jsonhttptest.WithPutResponseBody(&body), + ) +} + +// A module that shapes its response needs no node access, while one that reaches +// for the node's data on such a node is still invalid. +func TestExecuteEndToEndResponseWithoutNodeAccess(t *testing.T) { + t.Parallel() + + client := realEngineClient(t, api.ExecuteConfig{}) + addr := uploadModule(t, client, computeFixture(t, "respok")) + + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptFetch), + jsonhttptest.WithExpectedResponse([]byte("hi")), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/css"), + ) +} diff --git a/pkg/api/execute_response_test.go b/pkg/api/execute_response_test.go new file mode 100644 index 00000000000..ede55b3901a --- /dev/null +++ b/pkg/api/execute_response_test.go @@ -0,0 +1,276 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/compute" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" +) + +// Real Accept headers browsers send. A subresource request never names +// text/html, so before the format upgrade every one of these came back as a +// base64 JSON envelope and no module could serve a stylesheet or an image. +const ( + acceptStylesheet = "text/css,*/*;q=0.1" + acceptImage = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + acceptFetch = "*/*" + acceptNavigation = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" +) + +// okWith builds a clean result carrying the guest's response metadata. +func okWith(output string, status int, headers ...compute.Header) compute.Result { + return compute.Result{ + Status: compute.StatusOK, + Output: []byte(output), + Response: compute.ResponseMeta{Status: status, Headers: headers}, + } +} + +func TestExecuteGuestContentType(t *testing.T) { + t.Parallel() + + css := "body{color:red}" + + for _, tc := range []struct { + name string + accept string + wantType string + }{ + {"browser stylesheet request", acceptStylesheet, "text/css"}, + {"browser image request", acceptImage, "text/css"}, + {"default fetch", acceptFetch, "text/css"}, + {"no accept header at all", "", "text/css"}, + {"top-level navigation", acceptNavigation, "text/css"}, + {"explicit octet-stream", "application/octet-stream", "text/css"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: okWith(css, 0, + compute.Header{Name: "Content-Type", Value: "text/css"}, + compute.Header{Name: "Cache-Control", Value: "max-age=60"}, + )} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + opts := []jsonhttptest.Option{ + jsonhttptest.WithExpectedResponse([]byte(css)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType), + jsonhttptest.WithExpectedResponseHeader("Cache-Control", "max-age=60"), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + } + if tc.accept != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept)) + } + + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusOK, opts...) + }) + } +} + +// A module that sets nothing must behave exactly as it did before the response +// functions existed. This is the regression that keeps the change additive. +func TestExecuteWildcardWithoutMetadataStillEnvelope(t *testing.T) { + t.Parallel() + + output := []byte("plain result") + + for _, accept := range []string{"", acceptFetch, "application/*"} { + engine := &mockEngine{result: compute.Result{Status: compute.StatusOK, Output: output}} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + opts := []jsonhttptest.Option{} + var body []byte + opts = append(opts, jsonhttptest.WithPutResponseBody(&body)) + if accept != "" { + opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, accept)) + } + + jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, opts...) + + var resp struct { + Status string `json:"status"` + Output []byte `json:"output"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("accept %q: unmarshal %q: %v", accept, body, err) + } + if resp.Status != "ok" || !bytes.Equal(resp.Output, output) { + t.Errorf("accept %q: got %+v, want the unchanged envelope", accept, resp) + } + } +} + +// An explicit application/json is a request to be told about the run, so the +// guest's metadata is reported as fields and never applied to the transport. +func TestExecuteExplicitJSONReportsMetadata(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: okWith("

hi

", 404, + compute.Header{Name: "Content-Type", Value: "text/html"}, + compute.Header{Name: "Link", Value: "; rel=next"}, + compute.Header{Name: "Link", Value: "; rel=prev"}, + )} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"), + // The envelope is JSON regardless of what the module asked for. + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + jsonhttptest.WithPutResponseBody(&body), + ) + + var resp struct { + Status string `json:"status"` + HTTPStatus int `json:"httpStatus"` + Headers map[string][]string `json:"headers"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal %q: %v", body, err) + } + if resp.Status != "ok" { + t.Errorf("status: got %q, want ok", resp.Status) + } + if resp.HTTPStatus != 404 { + t.Errorf("httpStatus: got %d, want 404", resp.HTTPStatus) + } + if got := resp.Headers["Link"]; len(got) != 2 { + t.Errorf("Link: got %v, want both values in order", got) + } + if got := resp.Headers["Content-Type"]; len(got) != 1 || got[0] != "text/html" { + t.Errorf("Content-Type: got %v", got) + } +} + +func TestExecuteGuestStatusCode(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + status int + wantHTTP int + }{ + {"module reports not found", 404, http.StatusNotFound}, + {"module redirects", 303, http.StatusSeeOther}, + {"module reports its own failure", 500, http.StatusInternalServerError}, + {"unset falls back to 200", 0, http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: okWith("body", tc.status, + compute.Header{Name: "Content-Type", Value: "text/plain"}, + )} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), tc.wantHTTP, + jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptFetch), + jsonhttptest.WithExpectedResponse([]byte("body")), + // The verdict header stays truthful even when the module reports + // a 500 of its own: ok here, host-error if the node had failed. + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"), + ) + }) + } +} + +// The engine enforces the denylist; the API re-checks it so an engine bug cannot +// become a header leak. Asserted on the raw response, because what matters here +// is that the headers are absent. +func TestExecuteDeniedHeadersFilteredAtAPI(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: okWith("body", 0, + compute.Header{Name: "Content-Type", Value: "text/plain"}, + compute.Header{Name: "Access-Control-Allow-Origin", Value: "*"}, + compute.Header{Name: "Set-Cookie", Value: "sid=1"}, + compute.Header{Name: "Strict-Transport-Security", Value: "max-age=31536000"}, + compute.Header{Name: "Transfer-Encoding", Value: "chunked"}, + compute.Header{Name: api.SwarmWasmStatusHeader, Value: "trap"}, + )} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + req, err := http.NewRequest(http.MethodGet, "/@/"+addr.String(), nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(api.AcceptHeader, acceptFetch) + + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + for _, name := range []string{ + "Access-Control-Allow-Origin", + "Set-Cookie", + "Strict-Transport-Security", + "Transfer-Encoding", + } { + if got := resp.Header.Values(name); len(got) != 0 { + t.Errorf("%s leaked through: %v", name, got) + } + } + // The one header it was allowed to set still applies, so the filter is not + // simply dropping everything. + if got := resp.Header.Get(api.ContentTypeHeader); got != "text/plain" { + t.Errorf("content-type: got %q, want text/plain", got) + } + // And the verdict survived the guest's attempt to forge it. + if got := resp.Header.Get(api.SwarmWasmStatusHeader); got != "ok" { + t.Errorf("%s: got %q, want ok", api.SwarmWasmStatusHeader, got) + } +} + +// A client that negotiated HTML must not receive a JSON body when the module +// trapped: answering with the wrong content type is a lie regardless of status. +func TestExecuteTrapBodyMatchesNegotiatedType(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + accept string + wantType string + }{ + {"html", acceptNavigation, "text/html; charset=utf-8"}, + {"octet-stream", "application/octet-stream", "text/plain; charset=utf-8"}, + {"json", "application/json", "application/json; charset=utf-8"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + engine := &mockEngine{result: compute.Result{ + Status: compute.StatusTrap, + Output: []byte("partial"), + TrapMessage: "unreachable", + }} + client := newExecuteTestServer(t, engine, api.ExecuteConfig{}) + addr := uploadModule(t, client, []byte("module")) + + var body []byte + jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType), + jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"), + jsonhttptest.WithPutResponseBody(&body), + ) + if !bytes.Contains(body, []byte("unreachable")) { + t.Errorf("body %q does not name the trap", body) + } + }) + } +} diff --git a/pkg/api/host.go b/pkg/api/host.go index d54cb4ece6d..8e5dd9b119d 100644 --- a/pkg/api/host.go +++ b/pkg/api/host.go @@ -31,6 +31,13 @@ type executeHost struct { cache bool maxBytes uint64 + // sessionCtx scopes the upload session to the request rather than to the + // run. A host call's own context is the watchdog's, and that is cancelled + // the moment Execute returns — before Close commits — so a session opened + // on it can only ever fail to commit. The request context is what every + // other upload endpoint uses, and it is still live at Close. + sessionCtx context.Context + // mu guards the lazily opened session. A guest is single-threaded and // nested executions are sequential, but the session outlives individual // calls and is cheap to guard. @@ -41,10 +48,12 @@ type executeHost struct { var _ compute.Host = (*executeHost)(nil) -// newExecuteHost builds the per-request host. maxBytes is the execution's byte -// budget, used to refuse an oversized download before it is materialised. -func (s *Service) newExecuteHost(logger log.Logger, maxBytes uint64) *executeHost { - return &executeHost{s: s, logger: logger, cache: true, maxBytes: maxBytes} +// newExecuteHost builds the per-request host. ctx is the request's, and outlives +// the run so the upload session can still be committed. maxBytes is the +// execution's byte budget, used to refuse an oversized download before it is +// materialised. +func (s *Service) newExecuteHost(ctx context.Context, logger log.Logger, maxBytes uint64) *executeHost { + return &executeHost{s: s, logger: logger, cache: true, maxBytes: maxBytes, sessionCtx: ctx} } // BytesGet reassembles data of arbitrary length, as GET /bytes does. @@ -74,7 +83,7 @@ func (h *executeHost) BytesGet(ctx context.Context, addr swarm.Address) ([]byte, // Encryption and redundancy are deliberately not exposed to the guest: an // encrypted reference is 64 bytes and the guest ABI writes a fixed 32. func (h *executeHost) BytesPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) { - putter, err := h.putter(ctx, batchID) + putter, err := h.putter(batchID) if err != nil { return swarm.ZeroAddress, err } @@ -98,7 +107,7 @@ func (h *executeHost) ChunkGet(ctx context.Context, addr swarm.Address) ([]byte, // /chunks there is no single owner chunk path: a SOC needs a signature the // guest has no way to produce. func (h *executeHost) ChunkPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) { - putter, err := h.putter(ctx, batchID) + putter, err := h.putter(batchID) if err != nil { return swarm.ZeroAddress, err } @@ -115,11 +124,12 @@ func (h *executeHost) ChunkPut(ctx context.Context, batchID, data []byte) (swarm } // putter returns the execution's upload session, opening it on the first put so -// a module that never uploads never creates one. +// a module that never uploads never creates one. It takes no context: the +// session is scoped to sessionCtx, not to the call that happened to open it. // // One execution gets one session and therefore one batch: a put with a // different batch than the one that opened it is refused. -func (h *executeHost) putter(ctx context.Context, batchID []byte) (storer.PutterSession, error) { +func (h *executeHost) putter(batchID []byte) (storer.PutterSession, error) { h.mu.Lock() defer h.mu.Unlock() @@ -136,7 +146,7 @@ func (h *executeHost) putter(ctx context.Context, batchID []byte) (storer.Putter if err != nil { return nil, err } - session, err := h.s.newStamperPutter(ctx, putterOptions{ + session, err := h.s.newStamperPutter(h.sessionCtx, putterOptions{ BatchID: batchID, TagID: tag, // Deferred: a put returns once the chunk is stored locally and the diff --git a/pkg/api/host_test.go b/pkg/api/host_test.go index d4d75642b46..3797e8b4252 100644 --- a/pkg/api/host_test.go +++ b/pkg/api/host_test.go @@ -48,19 +48,32 @@ func loadHostFixture(t *testing.T, name string) []byte { // stores puts in a shared chunk store and its Cleanup is a no-op, so committing // and discarding look identical from the outside; this records which one the // handler actually chose. +// +// It also holds the session to the context it was opened with. The real upload +// store batches its writes against that context, so a session opened on one +// that dies before the run is finished can never be committed; the mock ignores +// the context entirely, which is what let a cancelled-by-construction session +// pass every test here and fail on a node. type sessionRecorder struct { storer.PutterSession + ctx context.Context done *atomic.Bool cleaned *atomic.Bool } func (s sessionRecorder) Done(addr swarm.Address) error { s.done.Store(true) + if err := s.ctx.Err(); err != nil { + return err + } return s.PutterSession.Done(addr) } func (s sessionRecorder) Cleanup() error { s.cleaned.Store(true) + if err := s.ctx.Err(); err != nil { + return err + } return s.PutterSession.Cleanup() } @@ -76,7 +89,7 @@ func (r *recordingStorer) Upload(ctx context.Context, pin bool, tagID uint64) (s if err != nil { return nil, err } - return sessionRecorder{PutterSession: session, done: &r.done, cleaned: &r.cleaned}, nil + return sessionRecorder{PutterSession: session, ctx: ctx, done: &r.done, cleaned: &r.cleaned}, nil } // newHostTestServer wires the real wazero engine behind the execute endpoint so diff --git a/pkg/api/router.go b/pkg/api/router.go index d3c3d6ed1d2..4aee6efb1a7 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -274,12 +274,20 @@ func (s *Service) mountAPI() { // Registered without a jsonhttp.MethodHandler on purpose: every HTTP method // reaches the module, which is told which one it was called with. - handle("/@/{address}", web.ChainHandlers( + // + // Both the bare and the trailing-path form serve. Unlike /bzz, which redirects + // the bare form to the directory form, /@/{address} is a function invocation: + // POST /@/{address} with a body is how a module is called, and most modules + // are pure compute for which a trailing slash means nothing. A module that + // wants the /bzz behaviour issues the redirect itself, using SCRIPT_NAME. + executeHandler := web.ChainHandlers( s.checkExecuteAvailability, s.contentLengthMetricMiddleware(), s.newTracingHandler("execute"), web.FinalHandlerFunc(s.executeHandler), - )) + ) + handle("/@/{address}", executeHandler) + handle("/@/{address}/{path:.*}", executeHandler) handle("/bytes/{address}", jsonhttp.MethodHandler{ "GET": web.ChainHandlers( diff --git a/pkg/compute/README.md b/pkg/compute/README.md index 552cf232923..377336421aa 100644 --- a/pkg/compute/README.md +++ b/pkg/compute/README.md @@ -1,6 +1,7 @@ # The `swarm` guest ABI -A module executed through `POST /@/{address}` runs in a WASI sandbox. Alongside +A module executed through `/@/{address}` — any method, with an optional trailing +path — runs in a WASI sandbox. Alongside `wasi_snapshot_preview1` it may import a host module named `swarm`, through which it reaches the node it is running on. @@ -22,8 +23,17 @@ it reaches the node it is running on. (func (param i32 i32 i32 i32) (result i32))) ;; batch_ptr, data_ptr, data_len, out_addr_ptr (import "swarm" "swarm_execute" (func (param i32 i32 i32 i32 i32 i32) (result i32)));; addr_ptr, input_ptr, input_len, buf_ptr, buf_len, out_len_ptr +(import "swarm" "swarm_response_status" + (func (param i32) (result i32))) ;; code +(import "swarm" "swarm_response_header" + (func (param i32 i32 i32 i32) (result i32))) ;; name_ptr, name_len, val_ptr, val_len ``` +The module is defined in two halves. The five data functions above exist only +when the node has node access; the two response functions always do, because +shaping a response causes no node work. A module that only sets a Content-Type +therefore runs on a node with node access switched off. + `bytes_*` moves data of arbitrary length through the same splitter and joiner the `/bytes` endpoints use. `chunk_*` is the raw single-chunk pair: `chunk_put` takes at most 4104 bytes (an 8-byte span followed by up to 4096 bytes of data) @@ -34,7 +44,12 @@ argument. `out_addr_ptr` must have 32 writable bytes. Importing a name the host module does not define, or any module other than `swarm` and `wasi_snapshot_preview1`, is rejected before the module runs — the -result is `invalid-module`, never a trap partway through. +result is `invalid-module`, never a trap partway through. Importing a data +function on a node without node access is rejected the same way. + +Note what is *not* checked: a module need not export its memory. It will not get +far without one — every call that moves bytes answers `INVALID` when there is no +memory to read — but that is a result code, not a rejection. ## Result codes @@ -104,11 +119,92 @@ unencrypted at the default redundancy level, which is what keeps a reference `swarm_execute` fetches a module from Swarm and runs it, handing it `input` on stdin and returning its stdout. The budgets are **shared across the whole call tree**, so a module cannot multiply its allowance by recursing. Nesting is -bounded by the depth limit; a cycle simply runs out of depth. +bounded by the depth limit; a cycle simply runs out of depth. The nested +module also inherits the Request metadata, but it is not allowed to shape the +response. A nested module is always run as a WASI command: the caller's `Swarm-Wasm-Entrypoint` applies to the outermost module only. +## Shaping the response + +By default the node decides how a module's output is rendered: the status comes +from the verdict and the content type from the caller's `Accept`. A module that +serves a web page needs to decide both itself, so it may set them: + +```wat +(call $response_status (i32.const 404)) +(call $response_header (local.get $name) (i32.const 12) (local.get $value) (i32.const 8)) +``` + +Both return a result code and neither charges the host-call budget, because +neither causes the node to do any work. They have their own bounds instead — +32 headers and 8 KiB of name-plus-value by default, `BUDGET_EXHAUSTED` beyond +that — and hard per-field caps of 128 bytes for a name and 4 KiB for a value. +Lengths are checked before any guest memory is read, so an absurd `val_len` costs +nothing. + +Rules worth knowing: + +- **Only the outermost execution has a response.** A module reached through + `swarm_execute` is a library call, not an HTTP request, so both functions + answer `DENIED` there rather than letting a fetched module rewrite its caller's + content type. +- **Only a clean run commits.** A module that traps sets no headers, exactly as + it stores nothing. Its partial *output* does survive — that is evidence about + what went wrong, while a header would be an instruction to follow. +- **A status must be 200–599.** 1xx is refused because an informational status + desynchronises the connection. 5xx is allowed: a module must be able to report + its own failure, and `Swarm-Wasm-Status` still says `ok`, which is what + distinguishes the module's 500 from the node's. +- **Some names are refused** with `DENIED`: `Swarm-Wasm-*` (the node's verdict + channel must not be forgeable), `Access-Control-*` (the node sets + `Access-Control-Allow-Credentials`, so a guest widening CORS would be a real + cross-origin credential leak), `Set-Cookie` and the origin-wide security + headers (every module shares the node's origin with its authenticated API), and + the hop-by-hop and framing headers. A malformed name or a value containing a + control character is `INVALID`, which subsumes CR/LF injection. + +How the metadata is rendered depends on what the caller asked for. `Accept: +application/json` reports it in the envelope as `httpStatus` and `headers` +without applying it — a client that asked to be told about the run did not ask to +have its transport reshaped. Every other representation applies it, the guest's +`Content-Type` replacing the negotiated default. A wildcard `Accept` reports by +default and applies once the module has set something, which is what lets a +browser load a stylesheet: a subresource request never names `text/html`. + +## Request metadata + +Exposed CGI-style. The host environment is never inherited, so a module sees the +same variables on every node. + +| Variable | Value | +|---|---| +| `REQUEST_METHOD` | the HTTP method the endpoint was called with | +| `SCRIPT_NAME` | the mount point, e.g. `/@/{address}` — what a module builds self-links from | +| `PATH_INFO` | the path after the address: empty for `/@/a`, `/` for `/@/a/`, `/x/y` for `/@/a/x/y` | +| `QUERY_STRING` | the raw, undecoded query | +| `REQUEST_URI` | the full request target | +| `CONTENT_TYPE`, `CONTENT_LENGTH` | of the request body | +| `HTTP_*` | allowlisted request headers, upper-cased with `-` replaced by `_` | + +Both `/@/{address}` and `/@/{address}/{path}` serve. Unlike `/bzz`, the bare form +is not redirected to the trailing-slash form: `POST /@/{address}` is how a module +is invoked, and most modules are pure compute for which a trailing slash means +nothing. A module that wants that redirect issues it itself, from `SCRIPT_NAME`. + +Request headers are an **allowlist**, the mirror image of the response denylist: +a response header carries only what the guest already knows, while a request +header carries what the operator's clients send. The default list is `Accept`, +`Accept-Language`, `Host`, `If-None-Match`, `If-Modified-Since`, `Range`, +`Referer`, `User-Agent`, `X-Requested-With` and `Swarm-Postage-Batch-Id`. +`--wasm-request-headers` replaces it. `Authorization`, `Proxy-Authorization` and +`Cookie` can never be forwarded whatever is configured, and a configuration +naming one stops the node at startup. + +The whole environment is capped (16 KiB by default). Overflow is a `431`, not a +truncation: a shortened environment would be a lie the module cannot detect. + ## Budgets | Bound | Default | Header | Stops | @@ -116,6 +212,12 @@ A nested module is always run as a WASI command: the caller's | host calls | 64 | `Swarm-Wasm-Host-Calls-Limit` | fetch amplification | | host bytes | 32 MiB | `Swarm-Wasm-Host-Bytes-Limit` | memory and bandwidth blowup | | depth | 4 | `Swarm-Wasm-Depth-Limit` | runaway recursion | +| response headers | 32 | — | unbounded response metadata | +| response header bytes | 8 KiB | — | the same, by size | + +The two response bounds carry no request header. The per-request overrides exist +so a *caller* can lower risk it is exposed to, and a caller is not exposed to +these: they cost it nothing, and lowering them could only break the module. Headers may only lower a limit; the operator's configured maximum wins. The byte budget is one pool counting both directions — what the node hands the guest and @@ -132,8 +234,7 @@ The whole of `wasi_snapshot_preview1` is available, `random_get` and and Go run without special builds. That is a prototype convenience, not a portability guarantee: a deterministic engine would restrict this surface. -Request metadata is exposed CGI-style: `REQUEST_METHOD` carries the HTTP method -the endpoint was called with. The host environment is never inherited. +Request metadata is exposed through the environment, as described above. ## Examples diff --git a/pkg/compute/engine.go b/pkg/compute/engine.go index 6dee2352a06..0f083b654b8 100644 --- a/pkg/compute/engine.go +++ b/pkg/compute/engine.go @@ -63,6 +63,38 @@ type Result struct { Status Status Output []byte TrapMessage string + // Response is the HTTP metadata the guest set through swarm_response_status + // and swarm_response_header. It is populated only on StatusOK: a module that + // trapped has no say in how its failure is rendered, exactly as a module that + // trapped commits no upload. + // + // Note the deliberate asymmetry with Output, which a trapped module does keep + // (see classifyRunError). Partial output is evidence about what went wrong; + // partial response metadata would be an instruction the node should not follow. + Response ResponseMeta +} + +// Header is one response header the guest set. Duplicates are kept in the order +// they were set, because Link and Vary legitimately repeat. +type Header struct { + Name string + Value string +} + +// ResponseMeta is the HTTP status and headers a guest asked for. Its zero value +// means the guest asked for nothing, which is the pre-existing behaviour and is +// what Empty reports. +type ResponseMeta struct { + // Status is the HTTP status code, or 0 when the guest did not set one. + Status int + // Headers are the accepted headers, in the order the guest set them. + Headers []Header +} + +// Empty reports whether the guest set no response metadata at all. The API layer +// uses it to decide whether a wildcard Accept still means "give me the envelope". +func (r ResponseMeta) Empty() bool { + return r.Status == 0 && len(r.Headers) == 0 } // Request describes a single execution: the module to run, the caller-supplied @@ -80,6 +112,17 @@ type Request struct { Method string // Input is the request body, handed to the guest on stdin. Input []byte + // Env carries the rest of the request metadata, CGI-style: PATH_INFO, + // QUERY_STRING, the allowlisted HTTP_* headers and so on. + // + // It is an ordered slice rather than a map because the guest can observe the + // order through environ_get, and Go's map iteration is random: a map would + // make a module's view of its own environment differ between two runs on the + // same node for no reason. + // + // REQUEST_METHOD comes from Method, not from here; a duplicate entry is + // ignored. The host environment is never inherited. + Env []EnvVar // Limits bound the execution. Limits Limits // Host serves the calls the module makes back into the node. It is @@ -90,6 +133,12 @@ type Request struct { Host Host } +// EnvVar is one environment variable the endpoint derived from the request. +type EnvVar struct { + Name string + Value string +} + // Engine executes a single WASM module in isolation and returns its Result. // // A non-nil error is reserved for infrastructure failures (the engine could not diff --git a/pkg/compute/export_test.go b/pkg/compute/export_test.go index 5bb86265e8a..23670dd788c 100644 --- a/pkg/compute/export_test.go +++ b/pkg/compute/export_test.go @@ -7,19 +7,42 @@ package compute import ( "context" + "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/tetratelabs/wazero" ) -// SwarmExports is the import allowlist checkImports enforces. -var SwarmExports = swarmExports +// SwarmResponseExports is the half of the import allowlist that needs no Host. +var SwarmResponseExports = swarmResponseExports -// SwarmModuleExports instantiates the swarm host module and reports the names -// it actually defines, so a test can hold it against SwarmExports. -func SwarmModuleExports(ctx context.Context) ([]string, error) { +// SwarmHostExports is the half of the import allowlist that reaches the node. +var SwarmHostExports = swarmHostExports + +// SwarmExports is the whole allowlist checkImports enforces when a Host is +// available. +func SwarmExports() map[string]struct{} { + all := make(map[string]struct{}, len(swarmResponseExports)+len(swarmHostExports)) + for name := range swarmResponseExports { + all[name] = struct{}{} + } + for name := range swarmHostExports { + all[name] = struct{}{} + } + return all +} + +// SwarmModuleExports instantiates the swarm host module and reports the names it +// actually defines, so a test can hold it against the allowlists. hostAvailable +// selects whether the data functions are registered, mirroring a node running +// with and without node access. +func SwarmModuleExports(ctx context.Context, hostAvailable bool) ([]string, error) { r := wazero.NewRuntime(ctx) defer r.Close(ctx) - if err := buildSwarmModule(ctx, r, &hostState{}); err != nil { + hs := &hostState{} + if hostAvailable { + hs.host = noopHost{} + } + if err := buildSwarmModule(ctx, r, hs); err != nil { return nil, err } @@ -29,3 +52,20 @@ func SwarmModuleExports(ctx context.Context) ([]string, error) { } return names, nil } + +// noopHost stands in for a Host so buildSwarmModule registers the data half. It +// is never called: the test only inspects the module's export list. +type noopHost struct{} + +func (noopHost) BytesGet(context.Context, swarm.Address) ([]byte, error) { + return nil, ErrNotFound +} +func (noopHost) BytesPut(context.Context, []byte, []byte) (swarm.Address, error) { + return swarm.ZeroAddress, ErrDenied +} +func (noopHost) ChunkGet(context.Context, swarm.Address) ([]byte, error) { + return nil, ErrNotFound +} +func (noopHost) ChunkPut(context.Context, []byte, []byte) (swarm.Address, error) { + return swarm.ZeroAddress, ErrDenied +} diff --git a/pkg/compute/host.go b/pkg/compute/host.go index ecb336a6119..733ea22f8e7 100644 --- a/pkg/compute/host.go +++ b/pkg/compute/host.go @@ -7,11 +7,13 @@ package compute import ( "context" "errors" + "strings" "github.com/ethersphere/bee/v2/pkg/log" "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" + "golang.org/x/net/http/httpguts" ) // Host serves the calls a running module makes back into the node. @@ -70,16 +72,133 @@ const ( // guest calling proc_exit with the same code is never mistaken for a host error. const exitCodeHostAbort uint32 = 0xBEE0 -// swarmExports is the exact set of functions the swarm host module defines. -// checkImports rejects anything outside it before instantiation, so an unknown -// swarm import is a deterministic StatusInvalidModule rather than a link trap. -// TestSwarmExportsMatchBuilder keeps this in step with buildSwarmModule. -var swarmExports = map[string]struct{}{ - "swarm_bytes_get": {}, - "swarm_bytes_put": {}, - "swarm_chunk_get": {}, - "swarm_chunk_put": {}, - "swarm_execute": {}, +// The swarm host module is defined in two halves, because they have different +// preconditions. checkImports rejects anything outside the applicable set before +// instantiation, so an unknown swarm import is a deterministic +// StatusInvalidModule rather than a link trap. +// TestSwarmExportsMatchBuilder keeps both in step with buildSwarmModule. +var ( + // swarmResponseExports shape the HTTP response. They cause no node work and + // are therefore always available, even when the node runs with node access + // switched off: setting a Content-Type is not a reason to need a Host. + swarmResponseExports = map[string]struct{}{ + "swarm_response_status": {}, + "swarm_response_header": {}, + } + // swarmHostExports reach the node's data and exist only when a Host does. + swarmHostExports = map[string]struct{}{ + "swarm_bytes_get": {}, + "swarm_bytes_put": {}, + "swarm_chunk_get": {}, + "swarm_chunk_put": {}, + "swarm_execute": {}, + } +) + +// Response headers a guest may not set. Everything else is allowed: a response +// header carries only what the guest already knows, so an allowlist here would be +// endless friction (Cache-Control, ETag, Location, Link, Vary, ...). The request +// direction is the opposite — see the allowlist in pkg/api, which guards the +// operator's secrets. +var ( + // A guest must not forge the node's own protocol namespace, and must not + // touch CORS: the node sets Access-Control-Allow-Credentials, so a guest + // setting Allow-Origin would be a genuine cross-origin credential leak. + deniedResponseHeaderPrefixes = []string{"swarm-wasm-", "access-control-"} + + deniedResponseHeaders = map[string]struct{}{ + // Hop-by-hop and framing: Go owns the wire format. + "connection": {}, + "keep-alive": {}, + "proxy-authenticate": {}, + "proxy-authorization": {}, + "te": {}, + "trailer": {}, + "transfer-encoding": {}, + "upgrade": {}, + "content-length": {}, + "host": {}, + // Every module shares the node's origin with the node's own authenticated + // API, so a guest-set cookie is session fixation across all of them. + // Revisit only behind a per-module origin. + "set-cookie": {}, + // Origin-wide and persistent: a guest must not be able to reconfigure or + // brick the operator's origin. + "strict-transport-security": {}, + "public-key-pins": {}, + "clear-site-data": {}, + } +) + +// DeniedResponseHeader reports whether a guest is forbidden from setting name. +// It is exported so the API layer can re-check before applying, keeping an engine +// bug from becoming a leak. +func DeniedResponseHeader(name string) bool { + lower := strings.ToLower(name) + for _, prefix := range deniedResponseHeaderPrefixes { + if strings.HasPrefix(lower, prefix) { + return true + } + } + _, denied := deniedResponseHeaders[lower] + return denied +} + +// responseState accumulates what the guest asked for through swarm_response_*. +// It belongs to the outermost execution alone; see hostState.resp. +type responseState struct { + status int + headers []Header + bytes uint32 + maxHeaders uint32 + maxBytes uint32 +} + +func newResponseState(l Limits) *responseState { + return &responseState{ + maxHeaders: l.ResponseHeaders(), + maxBytes: l.ResponseHeaderBytes(), + } +} + +// add records an accepted header, charging it against the two caps. +func (r *responseState) add(name, value string) uint32 { + if uint32(len(r.headers)) >= r.maxHeaders { + return errnoBudgetExhausted + } + // Subtract rather than add: bytes never exceeds maxBytes, so this cannot + // overflow the way bytes+size could for a large configured maximum. + size := uint32(len(name) + len(value)) + if size > r.maxBytes-r.bytes { + return errnoBudgetExhausted + } + r.bytes += size + r.headers = append(r.headers, Header{Name: name, Value: value}) + return errnoOK +} + +// snapshot renders what the guest set. A nil receiver is the no-metadata case. +func (r *responseState) snapshot() ResponseMeta { + if r == nil { + return ResponseMeta{} + } + return ResponseMeta{Status: r.status, Headers: r.headers} +} + +// validResponseHeaderValue accepts HTAB and printable ASCII only. +// +// This is deliberately stricter than RFC 9110, which also permits obs-text +// (0x80-0xFF): those bytes have no agreed encoding in a header value and are a +// smuggling surface. Rejecting control characters here subsumes CR/LF injection — +// Go's net/http would silently strip newlines on write, and a silent mangling is +// worse for a guest than a result code it can branch on. +func validResponseHeaderValue(value string) bool { + for i := 0; i < len(value); i++ { + if c := value[i]; c != '\t' && (c < 0x20 || c > 0x7e) { + return false + } + } + return true } // budget bounds the node work one execution tree may cause. It is shared by @@ -127,6 +246,11 @@ type hostState struct { // maxDepth bounds the number of execution levels, the outermost included. maxDepth uint32 logger log.Logger + // resp accumulates the guest's HTTP response metadata. It is non-nil only at + // depth 0: a module reached through swarm_execute is a library call, not an + // HTTP request, and must not be able to rewrite its caller's response. A nil + // resp is what makes the response calls return DENIED when nested. + resp *responseState // err records a node-local failure. When set, the run is aborted and its // verdict is StatusHostError regardless of how wazero reports the unwind. err error @@ -161,8 +285,28 @@ func classifyHostErr(err error) (uint32, bool) { } // buildSwarmModule instantiates the swarm host module against the runtime. +// +// The response functions are always defined; the data functions only when a Host +// is present, which is what makes an unavailable node a deterministic +// invalid-module for a data import while still letting any module set a +// Content-Type. func buildSwarmModule(ctx context.Context, r wazero.Runtime, h *hostState) error { - _, err := r.NewHostModuleBuilder(swarmModuleName). + b := r.NewHostModuleBuilder(swarmModuleName). + NewFunctionBuilder(). + WithFunc(h.responseStatus). + WithParameterNames("code"). + Export("swarm_response_status"). + NewFunctionBuilder(). + WithFunc(h.responseHeader). + WithParameterNames("name_ptr", "name_len", "val_ptr", "val_len"). + Export("swarm_response_header") + + if h.host == nil { + _, err := b.Instantiate(ctx) + return err + } + + _, err := b. NewFunctionBuilder(). WithFunc(h.bytesGet). WithParameterNames("addr_ptr", "buf_ptr", "buf_len", "out_len_ptr"). @@ -187,6 +331,61 @@ func buildSwarmModule(ctx context.Context, r wazero.Runtime, h *hostState) error return err } +// responseStatus sets the HTTP status the node will answer with. +// +// The last call wins. 1xx is refused because writing an informational status +// through an http.ResponseWriter desynchronises the connection; 5xx is allowed, +// because a module must be able to report its own failure and Swarm-Wasm-Status +// already distinguishes the guest's 500 (ok) from the node's (host-error). +func (h *hostState) responseStatus(_ context.Context, _ api.Module, code uint32) uint32 { + if h.resp == nil { + return errnoDenied + } + if code < 200 || code > 599 { + return errnoInvalid + } + h.resp.status = int(code) + return errnoOK +} + +// responseHeader appends a header to the response. +// +// The checks run in a fixed order and the SDK harness mirrors it exactly, so a +// module gets the same result code locally and on a node. Lengths are validated +// before any guest memory is read, so an absurd val_len never allocates, and a +// rejected call charges nothing. +func (h *hostState) responseHeader(_ context.Context, mod api.Module, namePtr, nameLen, valPtr, valLen uint32) uint32 { + if h.resp == nil { + return errnoDenied + } + mem := mod.Memory() + if mem == nil { + return errnoInvalid + } + if nameLen == 0 || nameLen > MaxResponseHeaderNameLen || valLen > MaxResponseHeaderValueLen { + return errnoInvalid + } + + nameBytes, ok := mem.Read(namePtr, nameLen) + if !ok { + return errnoInvalid + } + valueBytes, ok := mem.Read(valPtr, valLen) + if !ok { + return errnoInvalid + } + // Memory.Read aliases guest memory; string() copies. + name, value := string(nameBytes), string(valueBytes) + + if !httpguts.ValidHeaderFieldName(name) || !validResponseHeaderValue(value) { + return errnoInvalid + } + if DeniedResponseHeader(name) { + return errnoDenied + } + return h.resp.add(name, value) +} + func (h *hostState) bytesGet(ctx context.Context, mod api.Module, addrPtr, bufPtr, bufLen, outLenPtr uint32) uint32 { return h.get(ctx, mod, addrPtr, bufPtr, bufLen, outLenPtr, h.host.BytesGet) } @@ -220,6 +419,9 @@ func (h *hostState) get( } mem := mod.Memory() + if mem == nil { + return errnoInvalid + } addr, ok := readAddress(mem, addrPtr) if !ok { return errnoInvalid @@ -240,6 +442,9 @@ func (h *hostState) get( // length through outLenPtr so a too-small buffer can be retried. func (h *hostState) deliver(ctx context.Context, mod api.Module, bufPtr, bufLen, outLenPtr uint32, data []byte) uint32 { mem := mod.Memory() + if mem == nil { + return errnoInvalid + } if !mem.WriteUint32Le(outLenPtr, uint32(len(data))) { return errnoInvalid } @@ -277,6 +482,9 @@ func (h *hostState) put( } mem := mod.Memory() + if mem == nil { + return errnoInvalid + } batchID, ok := mem.Read(batchPtr, swarm.HashSize) if !ok { return errnoInvalid @@ -318,6 +526,9 @@ func (h *hostState) execute(ctx context.Context, mod api.Module, addrPtr, inputP } mem := mod.Memory() + if mem == nil { + return errnoInvalid + } addr, ok := readAddress(mem, addrPtr) if !ok { return errnoInvalid @@ -357,6 +568,10 @@ func (h *hostState) execute(ctx context.Context, mod api.Module, addrPtr, inputP } // readAddress reads a fixed-width Swarm address out of guest memory. +// +// Callers check mod.Memory() for nil first: a module may import the swarm +// functions and declare no memory at all (nothing in checkImports requires one), +// and api.Memory is a typed-nil interface in that case. func readAddress(mem api.Memory, ptr uint32) (swarm.Address, bool) { b, ok := mem.Read(ptr, swarm.HashSize) if !ok { diff --git a/pkg/compute/host_test.go b/pkg/compute/host_test.go index 4a43e0a7378..a446bf6d502 100644 --- a/pkg/compute/host_test.go +++ b/pkg/compute/host_test.go @@ -406,26 +406,43 @@ func TestSwarmExportsMatchBuilder(t *testing.T) { // checkImports rejects swarm imports outside the allowlist before the // module is instantiated. If the allowlist and the builder drift apart, a // real function becomes unreachable or a missing one becomes a link trap. - defined, err := compute.SwarmModuleExports(t.Context()) - if err != nil { - t.Fatal(err) - } + // + // The module is built in two halves: the response functions always, the data + // functions only when a Host is present. Both shapes are checked, because a + // drift in either is the same class of bug. + for _, tc := range []struct { + name string + hostAvailable bool + allowed map[string]struct{} + }{ + {"with node access", true, compute.SwarmExports()}, + {"without node access", false, compute.SwarmResponseExports}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - allowed := make([]string, 0, len(compute.SwarmExports)) - for name := range compute.SwarmExports { - allowed = append(allowed, name) - } - sort.Strings(allowed) - sort.Strings(defined) + defined, err := compute.SwarmModuleExports(t.Context(), tc.hostAvailable) + if err != nil { + t.Fatal(err) + } - if len(allowed) != len(defined) { - t.Fatalf("allowlist %v, host module defines %v", allowed, defined) - } - for i := range allowed { - if allowed[i] != defined[i] { - t.Errorf("allowlist %v, host module defines %v", allowed, defined) - break - } + allowed := make([]string, 0, len(tc.allowed)) + for name := range tc.allowed { + allowed = append(allowed, name) + } + sort.Strings(allowed) + sort.Strings(defined) + + if len(allowed) != len(defined) { + t.Fatalf("allowlist %v, host module defines %v", allowed, defined) + } + for i := range allowed { + if allowed[i] != defined[i] { + t.Errorf("allowlist %v, host module defines %v", allowed, defined) + break + } + } + }) } } diff --git a/pkg/compute/limits.go b/pkg/compute/limits.go index 93badd50e30..825f1f9cc4d 100644 --- a/pkg/compute/limits.go +++ b/pkg/compute/limits.go @@ -23,15 +23,40 @@ type Limits struct { // may reach, the outermost execution included, so 1 permits no nesting at // all. Zero means defaultMaxDepth. MaxDepth uint32 + // MaxResponseHeaders bounds how many response headers a guest may set. + // Zero means defaultMaxResponseHeaders. + MaxResponseHeaders uint32 + // MaxResponseHeaderBytes bounds the total size of the response headers a + // guest may set, counting name and value. Zero means + // defaultMaxResponseHeaderBytes. + // + // Unlike the host budgets these are not exposed as per-request headers. That + // pattern exists so a caller can lower risk it is exposed to, and a caller is + // not exposed to this one: setting a header costs the caller nothing, the + // node's exposure is a few kilobytes, and lowering it can only break the + // module. + MaxResponseHeaderBytes uint32 } // Defaults applied when a limit is left unset. They are deliberately modest: // this engine has no work-based bound, so the host budgets are what stop a // module from making the node fetch or store without end. const ( - defaultMaxHostCalls uint32 = 64 - defaultMaxHostBytes uint64 = 32 << 20 - defaultMaxDepth uint32 = 4 + defaultMaxHostCalls uint32 = 64 + defaultMaxHostBytes uint64 = 32 << 20 + defaultMaxDepth uint32 = 4 + defaultMaxResponseHeaders uint32 = 32 + defaultMaxResponseHeaderBytes uint32 = 8 << 10 +) + +// Hard caps on a single response header, not configurable. They bound the work a +// rejected call can cause: both are checked before any guest memory is read, so a +// value length of 2^32-1 never allocates. +const ( + // MaxResponseHeaderNameLen is the longest response header name accepted. + MaxResponseHeaderNameLen uint32 = 128 + // MaxResponseHeaderValueLen is the longest response header value accepted. + MaxResponseHeaderValueLen uint32 = 4 << 10 ) // HostCalls, HostBytes and Depth resolve a limit against its default. They are @@ -63,6 +88,23 @@ func (l Limits) Depth() uint32 { return l.MaxDepth } +// ResponseHeaders is the effective limit on how many headers a guest may set. +func (l Limits) ResponseHeaders() uint32 { + if l.MaxResponseHeaders == 0 { + return defaultMaxResponseHeaders + } + return l.MaxResponseHeaders +} + +// ResponseHeaderBytes is the effective limit on the total size of a guest's +// response headers, counting name and value. +func (l Limits) ResponseHeaderBytes() uint32 { + if l.MaxResponseHeaderBytes == 0 { + return defaultMaxResponseHeaderBytes + } + return l.MaxResponseHeaderBytes +} + const ( // wasmPageSize is the size of a single WebAssembly memory page. wasmPageSize = 65536 diff --git a/pkg/compute/response_test.go b/pkg/compute/response_test.go new file mode 100644 index 00000000000..e6a035cdd5f --- /dev/null +++ b/pkg/compute/response_test.go @@ -0,0 +1,346 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package compute_test + +import ( + "context" + "encoding/binary" + "strings" + "testing" + + "github.com/ethersphere/bee/v2/pkg/compute" +) + +// headerValues collects the values a guest set for one name, in order. +func headerValues(meta compute.ResponseMeta, name string) []string { + var out []string + for _, h := range meta.Headers { + if strings.EqualFold(h.Name, name) { + out = append(out, h.Value) + } + } + return out +} + +func TestResponseMetadata(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "respok"), + }) + if err != nil { + t.Fatal(err) + } + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + if string(res.Output) != "hi" { + t.Errorf("output: got %q, want %q", res.Output, "hi") + } + if res.Response.Status != 201 { + t.Errorf("status: got %d, want 201", res.Response.Status) + } + if got := headerValues(res.Response, "Content-Type"); len(got) != 1 || got[0] != "text/css" { + t.Errorf("content-type: got %v, want [text/css]", got) + } + if got := headerValues(res.Response, "Cache-Control"); len(got) != 1 || got[0] != "max-age=60" { + t.Errorf("cache-control: got %v, want [max-age=60]", got) + } + // Repeats are kept rather than collapsed: Link and Vary legitimately repeat. + if got := headerValues(res.Response, "Link"); len(got) != 1 || got[0] != "; rel=next" { + t.Errorf("link: got %v", got) + } + if res.Response.Empty() { + t.Error("Empty() reported true for a guest that set metadata") + } +} + +// A module that never calls the response functions must look exactly as it did +// before they existed, which is what lets the API layer keep its old behaviour. +func TestResponseMetadataAbsent(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "writer"), + }) + if err != nil { + t.Fatal(err) + } + if !res.Response.Empty() { + t.Errorf("Empty(): got false, want true (%+v)", res.Response) + } +} + +func TestResponseRefusals(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "respbad"), + }) + if err != nil { + t.Fatal(err) + } + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (nothing may trap)", res.Status, compute.StatusOK) + } + if len(res.Output) != 24 { + t.Fatalf("output: got %d bytes, want 24", len(res.Output)) + } + + for i, tc := range []struct { + name string + want uint32 + }{ + {"CR/LF in a header name", errnoInvalid}, + {"forging Swarm-Wasm-Status", errnoDenied}, + {"widening CORS", errnoDenied}, + {"setting a cookie", errnoDenied}, + {"a 99 status code", errnoInvalid}, + {"an absurd value length", errnoInvalid}, + } { + if got := binary.LittleEndian.Uint32(res.Output[i*4:]); got != tc.want { + t.Errorf("%s: got errno %d, want %d", tc.name, got, tc.want) + } + } + + // Every call was refused, so nothing may have been recorded. + if !res.Response.Empty() { + t.Errorf("refused calls left metadata behind: %+v", res.Response) + } +} + +func TestResponseHeaderCaps(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + limits compute.Limits + wantMax int + }{ + { + // 15 bytes a header ("X-Pad" + 10), so the count cap binds first. + name: "count cap", + limits: compute.Limits{MaxResponseHeaders: 4, MaxResponseHeaderBytes: 8 << 10}, + wantMax: 4, + }, + { + name: "byte cap", + limits: compute.Limits{MaxResponseHeaders: 1000, MaxResponseHeaderBytes: 45}, + wantMax: 3, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "respflood"), + Limits: tc.limits, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + + accepted := int(binary.LittleEndian.Uint32(res.Output[0:])) + code := binary.LittleEndian.Uint32(res.Output[4:]) + if accepted != tc.wantMax { + t.Errorf("accepted: got %d, want %d", accepted, tc.wantMax) + } + if code != errnoBudgetExhausted { + t.Errorf("stopping errno: got %d, want %d", code, errnoBudgetExhausted) + } + if len(res.Response.Headers) != tc.wantMax { + t.Errorf("recorded headers: got %d, want %d", len(res.Response.Headers), tc.wantMax) + } + }) + } +} + +// A trap discards response metadata the way it discards uploads, while partial +// output survives as evidence. The asymmetry is deliberate. +func TestResponseMetadataDroppedOnTrap(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "resptrap"), + }) + if err != nil { + t.Fatal(err) + } + if res.Status != compute.StatusTrap { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusTrap) + } + if !res.Response.Empty() { + t.Errorf("a trapped module kept its response metadata: %+v", res.Response) + } + if string(res.Output) != "part" { + t.Errorf("output: got %q, want %q (partial output is evidence)", res.Output, "part") + } +} + +// Shaping the response causes no node work, so it must not require a Host. The +// data functions must still be rejected in that configuration. +func TestResponseWithoutNodeAccess(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "respok"), + // No Host. + }) + if err != nil { + t.Fatal(err) + } + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + if res.Response.Status != 201 { + t.Errorf("status: got %d, want 201", res.Response.Status) + } +} + +// A module reached through swarm_execute is a library call, not an HTTP request. +// Letting it set headers would let a module fetched from Swarm rewrite its +// caller's response, so it is refused outright. +func TestResponseRefusedWhenNested(t *testing.T) { + t.Parallel() + + t.Run("outermost is allowed", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + res := runHost(t, host, "respcode", nil, compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK) + } + if got := binary.LittleEndian.Uint32(res.Output); got != errnoOK { + t.Errorf("errno: got %d, want %d", got, errnoOK) + } + if got := headerValues(res.Response, "X-Depth"); len(got) != 1 { + t.Errorf("header not recorded: %v", got) + } + }) + + t.Run("nested is denied", func(t *testing.T) { + t.Parallel() + + host := newMockHost() + addr := host.addData(2, loadModule(t, "respcode")) + + res := runHost(t, host, "hostnested", addr.Bytes(), compute.Limits{}) + if res.Status != compute.StatusOK { + t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage) + } + + fields, data := splitOutput(t, res.Output, 2) + if fields[0] != errnoOK { + t.Fatalf("swarm_execute errno: got %d, want %d", fields[0], errnoOK) + } + if got := binary.LittleEndian.Uint32(data); got != errnoDenied { + t.Errorf("nested errno: got %d, want %d", got, errnoDenied) + } + // The child's attempt must leave nothing on the parent's response. + if !res.Response.Empty() { + t.Errorf("a nested module set metadata on its caller: %+v", res.Response) + } + }) +} + +// splitEnv splits the environment block a guest dumped into its entries. +func splitEnv(out []byte) []string { + if len(out) == 0 { + return nil + } + return strings.Split(string(out), "\x00") +} + +func TestExecuteEnv(t *testing.T) { + t.Parallel() + + t.Run("entries reach the guest in the order given", func(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "method"), + Method: "GET", + Env: []compute.EnvVar{ + {Name: "SCRIPT_NAME", Value: "/@/abc"}, + {Name: "PATH_INFO", Value: "/style.css"}, + {Name: "QUERY_STRING", Value: "a=1&b=2"}, + {Name: "HTTP_ACCEPT", Value: "text/css"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + // Method still comes first: it owns REQUEST_METHOD. + "REQUEST_METHOD=GET", + "SCRIPT_NAME=/@/abc", + "PATH_INFO=/style.css", + "QUERY_STRING=a=1&b=2", + "HTTP_ACCEPT=text/css", + } + got := splitEnv(res.Output) + if len(got) != len(want) { + t.Fatalf("env: got %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("env[%d]: got %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("malformed entries are refused", func(t *testing.T) { + t.Parallel() + + s := newService(t, compute.Options{Workers: 1}) + + res, err := s.Execute(context.Background(), compute.Request{ + Module: loadModule(t, "method"), + Method: "GET", + Env: []compute.EnvVar{ + // A duplicate REQUEST_METHOD would give the guest two entries + // for one name. + {Name: "REQUEST_METHOD", Value: "PUT"}, + // "=" and NUL would corrupt the flat environ block. + {Name: "BAD=NAME", Value: "x"}, + {Name: "BAD\x00NAME", Value: "x"}, + {Name: "OK_NAME", Value: "kept"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"REQUEST_METHOD=GET", "OK_NAME=kept"} + got := splitEnv(res.Output) + if len(got) != len(want) { + t.Fatalf("env: got %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("env[%d]: got %q, want %q", i, got[i], want[i]) + } + } + }) +} diff --git a/pkg/compute/testdata/README.md b/pkg/compute/testdata/README.md index 59d7e495695..25f6077199f 100644 --- a/pkg/compute/testdata/README.md +++ b/pkg/compute/testdata/README.md @@ -34,5 +34,19 @@ module observes: | `hostputtrap` | `[32-byte batch id][payload]` | `[errno][32-byte reference]`, then traps | | `hostunknown` | — | — (imports a function the host module does not define) | +## Response fixtures + +These import `swarm_response_status` and `swarm_response_header`, which shape the +HTTP response and need no `Host` — `respok`, `respbad` and `respflood` run with +node access switched off. + +| Fixture | stdin | stdout | +|---|---|---| +| `respok` | — | `hi`, having set status 201 and three headers, one of them a repeat | +| `respbad` | — | `[6 x errno]`: CR/LF in a name, a `Swarm-Wasm-Status` override, an `Access-Control-*` name, `Set-Cookie`, status 99, a value length of `0xffffffff` | +| `respflood` | — | `[accepted count][errno that stopped the loop]` — observes the count and byte caps | +| `resptrap` | — | `part`, having set a status and a header, then traps | +| `respcode` | — | `[errno]` from one valid header: `OK` outermost, `DENIED` when nested | + Each fixture writes its payload field only when the call succeeded, so a non-zero result code yields the fixed-width fields alone. diff --git a/pkg/compute/testdata/method.wat b/pkg/compute/testdata/method.wat index d61649d303b..5e46d0f739a 100644 --- a/pkg/compute/testdata/method.wat +++ b/pkg/compute/testdata/method.wat @@ -1,6 +1,7 @@ -;; Writes the single environment entry the sandbox provides -;; ("REQUEST_METHOD=") to stdout, so tests can observe that request -;; metadata reaches the guest. +;; Writes the whole environment block the sandbox provides to stdout, so tests +;; can observe what request metadata reaches the guest and in what order. Entries +;; arrive as "NAME=value" separated by NUL; the trailing NUL is dropped, so a +;; single entry ("REQUEST_METHOD=POST") comes out as itself. ;; ;; environ_sizes_get stores the entry count at address 0 and the buffer size at ;; address 4; environ_get stores the pointer array at 64 and the NUL-terminated diff --git a/pkg/compute/testdata/respbad.wasm b/pkg/compute/testdata/respbad.wasm new file mode 100644 index 0000000000000000000000000000000000000000..5bcb2bb968dca2f97f070ed23152fdc8f3d45ab2 GIT binary patch literal 457 zcmZuty-ve05Wch15XC}jg+PdIj7S++*ceVsOdUG3TWVTELz^GjrV&#+FfcGwhzH>j zc!WLz5--Aq2qrw-;QRjW1MPAI0PM+0u;z#B2{r>jr1GfNE=ygZD>pBFT3NHoOnn~R z&eGau-T-u5t!;kzGgE6^RTj>q&TIeR0RP|4jh-0`AO(t!M8LfR5#}Z@?W2IOAzKeJ z_Gp1Nf*C%_y_o?M6|R6&sNS%-J{fU|$*{ed zbE&{vT>*zueVR^rp-r$M1}gMA5q)nnL}EW) INVALID (5) +;; a Swarm-Wasm-Status override -> DENIED (2) +;; Access-Control-Allow-Origin -> DENIED (2) +;; Set-Cookie -> DENIED (2) +;; status 99 -> INVALID (5) +;; a value length of 0xffffffff -> INVALID (5) +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_response_status" + (func $status (param i32) (result i32))) + (import "swarm" "swarm_response_header" + (func $header (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; iovec at 8 -> six results at 256 + (data (i32.const 8) "\00\01\00\00\18\00\00\00") + + (data (i32.const 64) "X\0d\0aInjected") ;; 64, len 11 + (data (i32.const 80) "v") ;; 80, len 1 + (data (i32.const 96) "Swarm-Wasm-Status") ;; 96, len 17 + (data (i32.const 128) "trap") ;; 128, len 4 + (data (i32.const 144) "Access-Control-Allow-Origin") ;; 144, len 27 + (data (i32.const 176) "*") ;; 176, len 1 + (data (i32.const 192) "Set-Cookie") ;; 192, len 10 + (data (i32.const 208) "a=b") ;; 208, len 3 + (data (i32.const 224) "X-Ok") ;; 224, len 4 + + (func (export "_start") + (i32.store (i32.const 256) + (call $header (i32.const 64) (i32.const 11) (i32.const 80) (i32.const 1))) + (i32.store (i32.const 260) + (call $header (i32.const 96) (i32.const 17) (i32.const 128) (i32.const 4))) + (i32.store (i32.const 264) + (call $header (i32.const 144) (i32.const 27) (i32.const 176) (i32.const 1))) + (i32.store (i32.const 268) + (call $header (i32.const 192) (i32.const 10) (i32.const 208) (i32.const 3))) + (i32.store (i32.const 272) + (call $status (i32.const 99))) + ;; An absurd length must be refused before any memory is read. + (i32.store (i32.const 276) + (call $header (i32.const 224) (i32.const 4) (i32.const 80) (i32.const 0xffffffff))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4))))) diff --git a/pkg/compute/testdata/respcode.wasm b/pkg/compute/testdata/respcode.wasm new file mode 100644 index 0000000000000000000000000000000000000000..af5b1e5e828bb58c52f3a35867c692c2079eb970 GIT binary patch literal 220 zcmX|*v1-FW5JYEJXG!E-7>rYx5Y(hhk;eUkKf>Mlz-y%9Byo2{r1G!j6Y@zpZ1BLF zY6c#B3Iu@Gd^Wcdx}5i3|_S9`szxrzdWDCwLNJ`^MQY~5KHs!;F%JQ7cU zcqA?$vC`~ln$djFz99gV^Ga;O(Pjm(;{8GUy7X3ee%Pqe*jzxL5KhV literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/respflood.wat b/pkg/compute/testdata/respflood.wat new file mode 100644 index 00000000000..ff4619fd57c --- /dev/null +++ b/pkg/compute/testdata/respflood.wat @@ -0,0 +1,31 @@ +;; Sets distinct headers in a loop until the node refuses, which is how the +;; count and byte caps are observed from inside the sandbox. +;; +;; stdout: [4-byte accepted count][4-byte errno that stopped the loop] +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_response_header" + (func $header (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; iovec at 8 -> two results at 256 + (data (i32.const 8) "\00\01\00\00\08\00\00\00") + (data (i32.const 64) "X-Pad") + (data (i32.const 80) "0123456789") + + (func (export "_start") (local $n i32) (local $code i32) + (block $done + (loop $next + ;; A distinct name is not needed: duplicates are accepted and each one + ;; charges the caps just the same. + (local.set $code + (call $header (i32.const 64) (i32.const 5) (i32.const 80) (i32.const 10))) + (br_if $done (i32.ne (local.get $code) (i32.const 0))) + (local.set $n (i32.add (local.get $n) (i32.const 1))) + ;; Stop well before forever if the caps were somehow not enforced. + (br_if $done (i32.gt_u (local.get $n) (i32.const 10000))) + (br $next))) + (i32.store (i32.const 256) (local.get $n)) + (i32.store (i32.const 260) (local.get $code)) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4))))) diff --git a/pkg/compute/testdata/respok.wasm b/pkg/compute/testdata/respok.wasm new file mode 100644 index 0000000000000000000000000000000000000000..d0fefb92246747542bc8ecfc64b5c130b9180b65 GIT binary patch literal 372 zcmZutyH3ME5S%?b#>yp;5rQH#5q%VBC?G*PrKO~IPRW8T;)m{zIH|%X@JY0Ed;*`q zCvYYb6)Vk3qurg+EVL~M038R5aPN!u3u+r6(%`JoHcxG-YnxRrtqpH-Zm#0TRoa-` zG0?G%HpS_0PYv6;DlMnhY4>75|4%d4D>gtNNOS@M9i51%;G!}+fv7z;4ncQHH&7px z&``ZY_0%U+oQR?Ng6gYpsBTMMNrmc-)SxA#VvjJvP|8j0*JV8N*@OqwNQx|nmy*4C zRXQ%+_+eKwysnHLZ{1{RExfwy&-F6nal2)zHN2PX7J56@PdvN6g!h!;eO^Ap8_U6T eqVH};hS#&wi{K(Gb-{EV;O|Qm9sVJrL-HTrhGQZC literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/respok.wat b/pkg/compute/testdata/respok.wat new file mode 100644 index 00000000000..7bc7908b4d9 --- /dev/null +++ b/pkg/compute/testdata/respok.wat @@ -0,0 +1,32 @@ +;; Sets a status and three response headers, then writes a body. The third +;; header repeats a name, which is legitimate (Link, Vary) and must be kept in +;; order rather than collapsed. +;; +;; stdout: "hi" +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_response_status" + (func $status (param i32) (result i32))) + (import "swarm" "swarm_response_header" + (func $header (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + ;; iovec at 8 -> the body at 32 + (data (i32.const 8) "\20\00\00\00\02\00\00\00") + (data (i32.const 32) "hi") + + ;; header names and values, laid out end to end + (data (i32.const 64) "Content-Type") ;; 64, len 12 + (data (i32.const 80) "text/css") ;; 80, len 8 + (data (i32.const 96) "Cache-Control") ;; 96, len 13 + (data (i32.const 112) "max-age=60") ;; 112, len 10 + (data (i32.const 128) "Link") ;; 128, len 4 + (data (i32.const 144) "; rel=next") ;; 144, len 14 + + (func (export "_start") + (drop (call $status (i32.const 201))) + (drop (call $header (i32.const 64) (i32.const 12) (i32.const 80) (i32.const 8))) + (drop (call $header (i32.const 96) (i32.const 13) (i32.const 112) (i32.const 10))) + (drop (call $header (i32.const 128) (i32.const 4) (i32.const 144) (i32.const 14))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4))))) diff --git a/pkg/compute/testdata/resptrap.wasm b/pkg/compute/testdata/resptrap.wasm new file mode 100644 index 0000000000000000000000000000000000000000..57fd301cda38827f74dddb4d2301ad64b573a52d GIT binary patch literal 286 zcmZvXF-`+96h!CS&azskScF2cX%eUrqNKFA0=;7?A7v%hYuSb+Rqnz`I08q)OC&13 zG}DaU*9=@52moEmojR4H(~f2Xgq7EmOTA6rg>)FR4Uyk_o*wF#y`5s88Cayr#bNd9 zHgXE1Pi#|m`7MF|PY>+&904gvEF^(0u0%EPFvg=mHN!DOHn+5e`IIen<_l(NzG0eH zbcRe_q*`>)`cc8uy55utst9JIRhaq0=;hPsGy8n^d<=~8KsT8`@_l%9y~mC6ZeUtV N{OzIY++0-Wjz5&IOhy0z literal 0 HcmV?d00001 diff --git a/pkg/compute/testdata/resptrap.wat b/pkg/compute/testdata/resptrap.wat new file mode 100644 index 00000000000..8c9403587bd --- /dev/null +++ b/pkg/compute/testdata/resptrap.wat @@ -0,0 +1,22 @@ +;; Sets a status and a header, writes output, then traps. The output survives as +;; evidence; the response metadata does not, exactly as a trapped module's +;; uploads do not. +(module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "swarm" "swarm_response_status" + (func $status (param i32) (result i32))) + (import "swarm" "swarm_response_header" + (func $header (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + + (data (i32.const 8) "\20\00\00\00\04\00\00\00") + (data (i32.const 32) "part") + (data (i32.const 64) "Content-Type") + (data (i32.const 80) "text/plain") + + (func (export "_start") + (drop (call $status (i32.const 418))) + (drop (call $header (i32.const 64) (i32.const 12) (i32.const 80) (i32.const 10))) + (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4))) + (unreachable))) diff --git a/pkg/compute/wazero.go b/pkg/compute/wazero.go index 5fd4e79566b..7df55281f92 100644 --- a/pkg/compute/wazero.go +++ b/pkg/compute/wazero.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/ethersphere/bee/v2/pkg/log" "github.com/tetratelabs/wazero" @@ -46,13 +47,24 @@ func newWazeroEngine(logger log.Logger) *wazeroEngine { // whatever the module writes to stdout as the result. A fresh runtime is created // per call so no state leaks between executions. func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) { - return e.execute(ctx, req, newBudget(req.Limits), 0) + // The response state is created once, here, and handed only to the outermost + // execution. Nested modules see a nil one and are refused. + return e.execute(ctx, req, newBudget(req.Limits), newResponseState(req.Limits), 0) } // execute runs one module at the given nesting depth. The budget is shared by // pointer across the whole call tree, so a module cannot multiply its host-call // allowance by recursing through swarm_execute. -func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, depth uint32) (Result, error) { +func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, resp *responseState, depth uint32) (res Result, err error) { + // Attached on the way out rather than at each return site: a run can finish + // through classifyRunError (a WASI exit 0 is a clean run reported as an error + // by wazero), and a return-site edit would miss that path. + defer func() { + if res.Status == StatusOK { + res.Response = resp.snapshot() + } + }() + e.logger.Debug("execute: starting", "module_size", len(req.Module), "input_size", len(req.Input), "method", req.Method, "entrypoint", req.Limits.Entrypoint, "memory_limit", req.Limits.Memory, "depth", depth) cfg := wazero.NewRuntimeConfig(). @@ -71,15 +83,21 @@ func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, dept // The swarm module is built per execution, closing over this tree's budget // and depth. Nothing is shared between untrusted programs. - var hs *hostState + // The swarm module is built per execution, closing over this tree's budget and + // depth, and is always instantiated: the response half needs no Host. + hs := &hostState{ + host: req.Host, + budget: b, + depth: depth, + maxDepth: req.Limits.Depth(), + logger: e.logger, + } + // Only the outermost execution owns the response. Nested modules get a nil + // responseState, which is what makes swarm_response_* return DENIED. + if depth == 0 { + hs.resp = resp + } if req.Host != nil { - hs = &hostState{ - host: req.Host, - budget: b, - depth: depth, - maxDepth: req.Limits.Depth(), - logger: e.logger, - } hs.nested = func(ctx context.Context, module, input []byte) (Result, error) { nested := req nested.Module = module @@ -87,12 +105,12 @@ func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, dept // A nested module is always run as a WASI command: the caller's // entrypoint header describes the outermost module only. nested.Limits.Entrypoint = "" - return e.execute(ctx, nested, b, depth+1) - } - if err := buildSwarmModule(ctx, r, hs); err != nil { - return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + return e.execute(ctx, nested, b, resp, depth+1) } } + if err := buildSwarmModule(ctx, r, hs); err != nil { + return Result{Status: StatusHostError, TrapMessage: err.Error()}, err + } compiled, err := r.CompileModule(ctx, req.Module) if err != nil { @@ -104,7 +122,9 @@ func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, dept // Reject anything the sandbox does not provide up front, so an unsatisfiable // import is a deterministic verdict on the module rather than a link failure // surfacing as a trap. - if err := checkImports(compiled, hs != nil); err != nil { + // hs is always non-nil now that the response half needs no Host, so node + // availability is req.Host, not the presence of a hostState. + if err := checkImports(compiled, req.Host != nil); err != nil { e.logger.Debug("execute: rejected import", "error", err) return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil } @@ -131,6 +151,21 @@ func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, dept if req.Method != "" { modCfg = modCfg.WithEnv(envRequestMethod, req.Method) } + for _, v := range req.Env { + // Method owns REQUEST_METHOD; a duplicate would give the guest two + // entries for one name. + if v.Name == envRequestMethod { + continue + } + // A name carrying "=" or NUL would corrupt the WASI environ block, which + // is a flat run of NUL-terminated "name=value" strings. The API layer + // sanitises too; this is the engine refusing to emit a malformed block + // whatever it is handed. + if strings.ContainsAny(v.Name, "=\x00") || strings.ContainsRune(v.Value, 0) { + continue + } + modCfg = modCfg.WithEnv(v.Name, v.Value) + } // With an explicit entrypoint, disable the automatic `_start` invocation and // call the named export ourselves after instantiation. @@ -210,10 +245,14 @@ func checkImports(compiled wazero.CompiledModule, hostAvailable bool) error { switch moduleName { case wasiModuleName: case swarmModuleName: + if _, ok := swarmResponseExports[name]; ok { + // Shaping the response causes no node work, so it needs no Host. + continue + } if !hostAvailable { return fmt.Errorf("import %q from module %q: node access is not available", name, moduleName) } - if _, ok := swarmExports[name]; !ok { + if _, ok := swarmHostExports[name]; !ok { return fmt.Errorf("unknown import %q from module %q", name, moduleName) } default: diff --git a/pkg/node/node.go b/pkg/node/node.go index fe68162d5ba..f3288a54d93 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -216,6 +216,10 @@ type Options struct { WasmMaxHostBytes uint64 WasmExecDepth uint64 WasmMaxExecDepth uint64 + WasmMaxResponseHeaders uint64 + WasmMaxResponseHeaderBytes uint64 + WasmRequestHeaders []string + WasmMaxEnvBytes uint64 } const ( @@ -1352,6 +1356,12 @@ func NewBee( var computeService compute.Engine if o.WasmExecuteEnable { + // A configuration that would hand an untrusted module the operator's + // credentials stops the node rather than producing a warning nobody + // reads. + if err := api.ValidateRequestHeaders(o.WasmRequestHeaders); err != nil { + return nil, fmt.Errorf("%s: %w", "wasm-request-headers", err) + } workers := o.WasmWorkers if workers < 1 { workers = min(runtime.NumCPU(), maxWasmWorkers) @@ -1402,6 +1412,11 @@ func NewBee( MaxHostBytes: o.WasmMaxHostBytes, DefaultDepth: o.WasmExecDepth, MaxDepth: o.WasmMaxExecDepth, + + MaxResponseHeaders: o.WasmMaxResponseHeaders, + MaxResponseHeaderBytes: o.WasmMaxResponseHeaderBytes, + RequestHeaders: o.WasmRequestHeaders, + MaxEnvBytes: o.WasmMaxEnvBytes, }, }