From 82512bcec105fda85e0d034f820cd9d7e76a3012 Mon Sep 17 00:00:00 2001 From: acud <12988138+acud@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:27:22 -0600 Subject: [PATCH] feat(bps): feed-topic binding, explicit-regime anchor semantics, status, bridge mux Relax the ANCHOR binding's address check under explicit publisher regimes per SWIP-60 (the SOC id does no protocol work there; legitimacy is list membership), add the FEED_TOPIC binding with FeedID derivation (keccak256(topic || 8-byte BE index), dedup on chunk address), expose per-topic Status() introspection, and add the Bridge: a per-topic mux sharing one p2p session among N local sinks with role upgrade, slow-sink drop and full teardown ownership. Also fixes a slice-sizing data race in Status() and documents the revised subscriber trust caveat. feat(api): pubsub websocket bridge Add GET /pubsub/{topic} (WebSocket session on a topic: cohort parameters in the query make the session the opener, owner makes it a publisher; swarm-keep-alive, swarm-soc-fields and swarm-cache-wrapped-chunk headers) and GET /pubsub (active topic listing). Inbound frames are sig||span||payload (anchor, id = topic) or index||sig||span||payload (feed, id = keccak256(topic || index)), assembled and signature-authenticated node-side against the cohort spec - the node holds no publisher keys. Includes SWIP-60 conformance end-to-end tests: jam cohort, live feed stream with dedup, and FULL-refusal with a wire-level Hello/Ack-only assertion. feat(node): wire bps service and bridge Construct the bps service and bridge in the node bootstrap, register the broker protocol on full nodes only, expose the bridge to the API, register metrics, close on shutdown, and add the pubsub-capacity flag (default 32) for the per-topic broker stream limit. docs(openapi): document pubsub endpoints Add /pubsub and /pubsub/{topic} to the Swarm API spec (8.1.0 -> 8.2.0) with the cohort query parameters, the swarm-keep-alive, swarm-soc-fields and swarm-cache-wrapped-chunk headers, publish frame formats, and the subscriber trust caveat. --- cmd/bee/cmd/cmd.go | 2 + cmd/bee/cmd/start.go | 1 + openapi/Swarm.yaml | 138 +- openapi/SwarmCommon.yaml | 82 ++ pkg/api/api.go | 13 + pkg/api/api_test.go | 2 + pkg/api/bps.go | 200 +++ pkg/api/bps_e2e_test.go | 419 ++++++ pkg/api/bps_test.go | 264 ++++ pkg/api/bps_ws.go | 528 +++++++ pkg/api/bps_ws_test.go | 632 ++++++++ pkg/api/export_test.go | 14 + pkg/api/router.go | 8 + pkg/bps/binding.go | 106 ++ pkg/bps/binding_test.go | 360 +++++ pkg/bps/bps.go | 252 ++++ pkg/bps/bps_status_test.go | 94 ++ pkg/bps/bridge.go | 422 ++++++ pkg/bps/bridge_test.go | 400 ++++++ pkg/bps/broadcast_test.go | 487 +++++++ pkg/bps/broker.go | 458 ++++++ pkg/bps/cohort.go | 136 ++ pkg/bps/cohort_test.go | 147 ++ pkg/bps/export_test.go | 42 + pkg/bps/frame.go | 101 ++ pkg/bps/frame_test.go | 114 ++ pkg/bps/handshake_test.go | 341 +++++ pkg/bps/hostile_test.go | 305 ++++ pkg/bps/metrics.go | 68 + pkg/bps/mock/mock.go | 73 + pkg/bps/mock/mock_test.go | 47 + pkg/bps/pb/bps.pb.go | 2780 ++++++++++++++++++++++++++++++++++++ pkg/bps/pb/bps.proto | 113 ++ pkg/bps/pb/bps_test.go | 73 + pkg/bps/pb/doc.go | 7 + pkg/bps/publisher.go | 49 + pkg/bps/session.go | 308 ++++ pkg/bps/session_test.go | 233 +++ pkg/bps/testing/bps.go | 62 + pkg/node/node.go | 14 + 40 files changed, 9894 insertions(+), 1 deletion(-) create mode 100644 pkg/api/bps.go create mode 100644 pkg/api/bps_e2e_test.go create mode 100644 pkg/api/bps_test.go create mode 100644 pkg/api/bps_ws.go create mode 100644 pkg/api/bps_ws_test.go create mode 100644 pkg/bps/binding.go create mode 100644 pkg/bps/binding_test.go create mode 100644 pkg/bps/bps.go create mode 100644 pkg/bps/bps_status_test.go create mode 100644 pkg/bps/bridge.go create mode 100644 pkg/bps/bridge_test.go create mode 100644 pkg/bps/broadcast_test.go create mode 100644 pkg/bps/broker.go create mode 100644 pkg/bps/cohort.go create mode 100644 pkg/bps/cohort_test.go create mode 100644 pkg/bps/export_test.go create mode 100644 pkg/bps/frame.go create mode 100644 pkg/bps/frame_test.go create mode 100644 pkg/bps/handshake_test.go create mode 100644 pkg/bps/hostile_test.go create mode 100644 pkg/bps/metrics.go create mode 100644 pkg/bps/mock/mock.go create mode 100644 pkg/bps/mock/mock_test.go create mode 100644 pkg/bps/pb/bps.pb.go create mode 100644 pkg/bps/pb/bps.proto create mode 100644 pkg/bps/pb/bps_test.go create mode 100644 pkg/bps/pb/doc.go create mode 100644 pkg/bps/publisher.go create mode 100644 pkg/bps/session.go create mode 100644 pkg/bps/session_test.go create mode 100644 pkg/bps/testing/bps.go diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index 658012fabce..4445d95d6f1 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -50,6 +50,7 @@ const ( optionNamePaymentThreshold = "payment-threshold" optionNamePaymentTolerance = "payment-tolerance-percent" optionNamePaymentEarly = "payment-early-percent" + optionNamePubsubCapacity = "pubsub-capacity" optionNameResolverEndpoints = "resolver-options" optionNameBootnodeMode = "bootnode-mode" optionNameBzzTokenAddress = "bzz-token-address" @@ -347,6 +348,7 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().String(optionNamePaymentThreshold, "13500000", "threshold in BZZ where you expect to get paid from your peers") cmd.Flags().Int64(optionNamePaymentTolerance, 25, "excess debt above payment threshold in percentages where you disconnect from your peer") cmd.Flags().Int64(optionNamePaymentEarly, 50, "percentage below the peers payment threshold when we initiate settlement") + cmd.Flags().Int(optionNamePubsubCapacity, 32, "per-topic connection capacity this node offers as a pubsub broker") cmd.Flags().StringSlice(optionNameResolverEndpoints, []string{}, "ENS compatible API endpoint for a TLD and with contract address, can be repeated, format [tld:][contract-addr@]url") cmd.Flags().Bool(optionNameBootnodeMode, false, "cause the node to always accept incoming connections") cmd.Flags().String(optionNameBlockchainRpcEndpoint, "", "rpc blockchain endpoint") diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index 7e7ab4a27cd..680fc4b96a5 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -308,6 +308,7 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo BlockSyncInterval: c.config.GetUint64(optionNameBlockSyncInterval), BootnodeMode: bootNode, Bootnodes: networkConfig.bootNodes, + BpsCapacity: c.config.GetInt(optionNamePubsubCapacity), CacheCapacity: c.config.GetUint64(optionNameCacheCapacity), AutoTLSCAEndpoint: c.config.GetString(optionAutoTLSCAEndpoint), ChainID: networkConfig.chainID, diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index c320410b4ee..c65d8b4ddf1 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: - version: 8.1.1 + version: 8.2.0 title: Bee API description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management" @@ -979,6 +979,142 @@ paths: default: description: Default response + "/pubsub": + get: + summary: List active pubsub topics + tags: + - PubSub + responses: + "200": + description: List of pubsub topics this node currently participates in + content: + application/json: + schema: + type: array + items: + $ref: "SwarmCommon.yaml#/components/schemas/BpsTopicStatus" + "501": + description: Pubsub is not enabled on this node + default: + description: Default response + + "/pubsub/{topic}": + get: + summary: Open or join a pubsub topic (WebSocket) + description: > + Upgrades the connection to a WebSocket attached to one pubsub topic. Presence of any + cohort query parameter (binding, publishers, admin, publisher-list, closed, history) + opens the cohort for that topic; their absence subscribes to a topic that is already + open. Presence of the owner query parameter makes the session a publisher. + + + Signing of publish frames is performed client-side; the node holds no publisher keys. + For an ANCHOR-bound topic, inbound publisher frames are binary + `sig(65) || span(8) || payload`, and the Single Owner Chunk id is the topic itself. For a + FEED_TOPIC-bound topic, inbound publisher frames are binary + `index(8, big-endian) || sig(65) || span(8) || payload`, and the id is + keccak256(topic || index). There is no `index` query parameter: the feed index rides on + every publish frame. + + + Outbound (subscriber) messages are payload-only binary frames by default, or JSON text + frames with hex-encoded fields when Swarm-Soc-Fields names more than payload alone. Under + an explicit publisher regime, a subscriber has no way to independently verify a + publisher's cohort spec ahead of the broker's own enforcement; treat delivered messages + accordingly until the broker vouches for the cohort. + + + A second local WebSocket client attaching to a topic that is already open on this node is + muxed onto the existing p2p session rather than admitted as a new one: closed-cohort and + capacity checks are enforced by the broker per p2p session, not per local WS client. + tags: + - PubSub + parameters: + - in: path + name: topic + schema: + type: string + required: true + description: > + Topic name: either a 64-hex-character string naming a 32-byte topic directly, or any + other string, which is hashed (keccak256) into a topic as a mnemonic. + - in: query + name: peer + schema: + type: string + required: true + description: Underlay multiaddr of the broker peer for this topic. + - in: query + name: binding + schema: + type: string + enum: + - anchor + - feed + required: false + description: How a publish frame's Single Owner Chunk id is derived. Presence of this or any other cohort parameter opens the cohort. + - in: query + name: publishers + schema: + type: string + enum: + - single + - list + required: false + description: Whether the cohort has one publisher (single) or an explicit publisher list (list). + - in: query + name: admin + schema: + $ref: "SwarmCommon.yaml#/components/schemas/EthereumAddress" + required: false + description: Ethereum address of the cohort's admin. + - in: query + name: publisher-list + schema: + type: string + required: false + description: Comma-separated list of 20-byte hex Ethereum addresses allowed to publish, used with `publishers=list`. + - in: query + name: closed + schema: + type: boolean + required: false + description: Whether the cohort is closed to publishers not on the publisher list. + - in: query + name: history + schema: + type: boolean + required: false + description: "Whether the cohort should deliver history to new subscribers. Not supported by this node; a cohort spec naming `history: true` is refused." + - in: query + name: owner + schema: + $ref: "SwarmCommon.yaml#/components/schemas/EthereumAddress" + required: false + description: Ethereum address of the publisher signing outbound frames. Presence of this parameter makes the session a publisher. + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmKeepAlive" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmSocFields" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmCacheWrappedChunk" + responses: + "101": + description: Switching protocols; a pubsub WebSocket session for the topic is established + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + "403": + description: The request was rejected — the cohort is closed, or the caller is not a publisher of the cohort + "404": + description: Unknown topic + "409": + description: The requested cohort specification conflicts with the cohort's live specification + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + "501": + description: Pubsub is not enabled on this node + "503": + description: The broker is at capacity for this topic + default: + description: Default response + "/soc/{owner}/{id}": post: summary: Upload a Single Owner Chunk diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ffcbbac3b8e..134e3ddb016 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -1075,6 +1075,56 @@ components: properties: transactionHash: $ref: "#/components/schemas/TransactionHash" + + BpsCohortSpec: + type: object + description: The cohort specification of a pubsub topic, when known. + properties: + binding: + type: string + enum: + - anchor + - feed + description: How a publish frame's Single Owner Chunk id is derived. "anchor" uses the topic itself as the id; "feed" derives the id from the topic and the 8-byte big-endian index carried on every publish frame. + publishers: + type: string + enum: + - single + - list + description: Whether the cohort has one publisher (single) or an explicit publisher list (list). + admin: + type: string + description: Hex-encoded (no 0x prefix) 20-byte Ethereum address of the cohort's admin, or empty when unset. + publisherList: + type: array + items: + type: string + description: Hex-encoded (no 0x prefix) 20-byte Ethereum address. + closed: + type: boolean + description: Whether the cohort is closed to publishers not on the publisher list. + history: + type: boolean + description: Whether the cohort delivers history to new subscribers. Not supported by this node. + + BpsTopicStatus: + type: object + description: The status of one pubsub topic this node participates in. + properties: + topic: + $ref: "#/components/schemas/SwarmAddress" + role: + type: string + enum: + - broker + - client + description: Whether this node brokers the topic or is a client of it. + peers: + type: integer + description: Number of peers currently attached to the topic on this node. + cohort: + $ref: "#/components/schemas/BpsCohortSpec" + headers: SwarmTag: description: "Tag UID" @@ -1331,6 +1381,38 @@ components: required: false description: "ACT history Unix timestamp" + SwarmKeepAlive: + in: header + name: Swarm-Keep-Alive + schema: + type: integer + format: int64 + default: 60 + required: false + description: "Ping period, in seconds, of the pubsub websocket session. Must be a positive integer. Default: 60" + + SwarmSocFields: + in: header + name: Swarm-Soc-Fields + schema: + type: string + required: false + description: > + Comma-separated list of Single Owner Chunk fields to include in outbound pubsub websocket + messages: address, recoveredPubKey, identifier, signature, wrappedAddress, span, payload. + Default: payload. When the selection is payload only, messages are sent as binary frames + containing the raw payload; any other selection is sent as a JSON text frame with + hex-encoded values for exactly the requested fields. + + SwarmCacheWrappedChunk: + in: header + name: Swarm-Cache-Wrapped-Chunk + schema: + type: boolean + default: "false" + required: false + description: "Indicates whether each message's wrapped chunk should be stored in the local cache. Default: false" + responses: "200": description: Success diff --git a/pkg/api/api.go b/pkg/api/api.go index 63a04c390ff..39e6edd799e 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/bps" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds" "github.com/ethersphere/bee/v2/pkg/file/pipeline" @@ -96,6 +97,9 @@ const ( SwarmActTimestampHeader = "Swarm-Act-Timestamp" SwarmActPublisherHeader = "Swarm-Act-Publisher" SwarmActHistoryAddressHeader = "Swarm-Act-History-Address" + SwarmKeepAliveHeader = "Swarm-Keep-Alive" + SwarmSocFieldsHeader = "Swarm-Soc-Fields" + SwarmCacheWrappedChunkHeader = "Swarm-Cache-Wrapped-Chunk" ImmutableHeader = "Immutable" GasPriceHeader = "Gas-Price" @@ -149,6 +153,12 @@ type Storer interface { storer.NeighborhoodStats } +// BpsBridge is the surface of pkg/bps the API needs. +type BpsBridge interface { + Attach(ctx context.Context, o bps.AttachOptions) (bps.Attachment, error) + Status() []bps.TopicStatus +} + type PinIntegrity interface { Check(ctx context.Context, logger log.Logger, pin string, out chan storer.PinStat) } @@ -158,6 +168,7 @@ type Service struct { resolver resolver.Interface pss pss.Interface gsoc gsoc.Listener + bps BpsBridge steward steward.Interface logger log.Logger loggerV1 log.Logger @@ -264,6 +275,7 @@ type ExtraOptions struct { Resolver resolver.Interface Pss pss.Interface Gsoc gsoc.Listener + Bps BpsBridge FeedFactory feeds.Factory Post postage.Service AccessControl accesscontrol.Controller @@ -345,6 +357,7 @@ func (s *Service) Configure(signer crypto.Signer, tracer *tracing.Tracer, o Opti s.resolver = e.Resolver s.pss = e.Pss s.gsoc = e.Gsoc + s.bps = e.Bps s.feedFactory = e.FeedFactory s.post = e.Post s.accesscontrol = e.AccessControl diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 23782bcbc6b..745fc91854c 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -95,6 +95,7 @@ type testServerOptions struct { Resolver resolver.Interface Pss pss.Interface Gsoc gsoc.Listener + Bps api.BpsBridge WsPath string WsPingPeriod time.Duration Logger log.Logger @@ -202,6 +203,7 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket. Resolver: o.Resolver, Pss: o.Pss, Gsoc: o.Gsoc, + Bps: o.Bps, FeedFactory: o.Feeds, Post: o.Post, AccessControl: o.AccessControl, diff --git a/pkg/api/bps.go b/pkg/api/bps.go new file mode 100644 index 00000000000..e1ac7988c6d --- /dev/null +++ b/pkg/api/bps.go @@ -0,0 +1,200 @@ +// 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" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/cac" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/gorilla/websocket" +) + +// feedIndexSize is the width in bytes of the big-endian feed index prefix on +// a FEED_TOPIC publish frame. +const feedIndexSize = 8 + +// parsePublishFrame decodes one inbound binary WS frame into a signed SOC. +// +// ANCHOR (explicit regimes): sig(65) ‖ span(8) ‖ payload — id = topic bytes. +// FEED_TOPIC: index(8, big-endian) ‖ sig(65) ‖ span(8) ‖ payload — id = +// bps.FeedID(topic, index). +func parsePublishFrame(binding pb.TopicBinding, topic swarm.Address, owner []byte, frame []byte) (*soc.SOC, error) { + var id []byte + rest := frame + + switch binding { + case pb.TopicBinding_ANCHOR: + if len(rest) < swarm.SocSignatureSize+swarm.SpanSize { + return nil, fmt.Errorf("bps: publish frame too short for anchor binding") + } + id = topic.Bytes() + case pb.TopicBinding_FEED_TOPIC: + if len(rest) < feedIndexSize+swarm.SocSignatureSize+swarm.SpanSize { + return nil, fmt.Errorf("bps: publish frame too short for feed-topic binding") + } + index := binary.BigEndian.Uint64(rest[:feedIndexSize]) + fid, err := bps.FeedID(topic.Bytes(), index) + if err != nil { + return nil, fmt.Errorf("bps: derive feed id: %w", err) + } + id = fid + rest = rest[feedIndexSize:] + default: + return nil, fmt.Errorf("bps: unsupported topic binding %v", binding) + } + + sig := rest[:swarm.SocSignatureSize] + spanPayload := rest[swarm.SocSignatureSize:] + payload := spanPayload[swarm.SpanSize:] + if len(payload) > swarm.ChunkSize { + return nil, fmt.Errorf("bps: publish frame payload exceeds chunk size") + } + + ch, err := cac.NewWithDataSpan(spanPayload) + if err != nil { + return nil, fmt.Errorf("bps: assemble wrapped chunk: %w", err) + } + + sc, err := soc.NewSigned(id, ch, owner, sig) + if err != nil { + return nil, fmt.Errorf("bps: assemble soc: %w", err) + } + + if err := verifyPublishSignature(id, ch.Address(), owner, sig); err != nil { + return nil, err + } + + return sc, nil +} + +// verifyPublishSignature recovers the signer of id ‖ chunkAddress from sig +// and confirms it matches owner. This is the invalid-signature path. +func verifyPublishSignature(id []byte, chunkAddr swarm.Address, owner, sig []byte) error { + h := swarm.NewHasher() + if _, err := h.Write(id); err != nil { + return fmt.Errorf("bps: hash soc digest: %w", err) + } + if _, err := h.Write(chunkAddr.Bytes()); err != nil { + return fmt.Errorf("bps: hash soc digest: %w", err) + } + digest := h.Sum(nil) + + pubKey, err := crypto.Recover(sig, digest) + if err != nil { + return fmt.Errorf("bps: invalid signature: %w", err) + } + recovered, err := crypto.NewEthereumAddress(*pubKey) + if err != nil { + return fmt.Errorf("bps: invalid signature: %w", err) + } + if !bytes.Equal(recovered, owner) { + return fmt.Errorf("bps: invalid signature: owner mismatch") + } + return nil +} + +// socFields is the parsed swarm-soc-fields header naming which fields of a +// SOC an outbound WS message should carry. +type socFields struct { + address, recoveredPubKey, identifier, signature, wrappedAddress, span, payload bool +} + +// parseSocFields parses the comma-separated swarm-soc-fields header value. +// An empty header selects payload only. +func parseSocFields(header string) (socFields, error) { + var f socFields + + header = strings.TrimSpace(header) + if header == "" { + f.payload = true + return f, nil + } + + for _, part := range strings.Split(header, ",") { + switch strings.TrimSpace(part) { + case "address": + f.address = true + case "recoveredPubKey": + f.recoveredPubKey = true + case "identifier": + f.identifier = true + case "signature": + f.signature = true + case "wrappedAddress": + f.wrappedAddress = true + case "span": + f.span = true + case "payload": + f.payload = true + default: + return socFields{}, fmt.Errorf("bps: unknown soc field %q", strings.TrimSpace(part)) + } + } + return f, nil +} + +// serializeSoc renders one outbound message for sc according to f. A +// payload-only selection produces a raw binary frame; any other selection +// produces a JSON text frame with hex-encoded values for exactly the +// requested fields. +func serializeSoc(f socFields, sc *soc.SOC) (msgType int, data []byte, err error) { + wrapped := sc.WrappedChunk() + wrappedData := wrapped.Data() + var span, payload []byte + if len(wrappedData) >= swarm.SpanSize { + span = wrappedData[:swarm.SpanSize] + payload = wrappedData[swarm.SpanSize:] + } else { + payload = wrappedData + } + + if f.payload && !f.address && !f.recoveredPubKey && !f.identifier && !f.signature && !f.wrappedAddress && !f.span { + return websocket.BinaryMessage, payload, nil + } + + out := make(map[string]string) + + if f.address { + addr, err := sc.Address() + if err != nil { + return 0, nil, fmt.Errorf("bps: soc address: %w", err) + } + out["address"] = hex.EncodeToString(addr.Bytes()) + } + if f.recoveredPubKey { + out["recoveredPubKey"] = hex.EncodeToString(sc.OwnerPubKey()) + } + if f.identifier { + out["identifier"] = hex.EncodeToString(sc.ID()) + } + if f.signature { + out["signature"] = hex.EncodeToString(sc.Signature()) + } + if f.wrappedAddress { + out["wrappedAddress"] = hex.EncodeToString(wrapped.Address().Bytes()) + } + if f.span { + out["span"] = hex.EncodeToString(span) + } + if f.payload { + out["payload"] = hex.EncodeToString(payload) + } + + data, err = json.Marshal(out) + if err != nil { + return 0, nil, fmt.Errorf("bps: marshal soc fields: %w", err) + } + return websocket.TextMessage, data, nil +} diff --git a/pkg/api/bps_e2e_test.go b/pkg/api/bps_e2e_test.go new file mode 100644 index 00000000000..d4dcba0a369 --- /dev/null +++ b/pkg/api/bps_e2e_test.go @@ -0,0 +1,419 @@ +// 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" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/gorilla/websocket" + ma "github.com/multiformats/go-multiaddr" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/bzz" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/p2p/streamtest" + "github.com/ethersphere/bee/v2/pkg/soc" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// The tests in this file are the SWIP-60 §5 conformance scenarios end to end: +// a real broker service, real client services and real bridges over a +// streamtest network, driven only through the pubsub WebSocket API. Nothing +// here is faked except the peer transport and the Connecter that resolves a +// broker underlay to its overlay. + +// e2eUnderlay is the broker underlay every attach names. It is never dialed: +// e2eConnecter answers it with the broker's overlay. +const e2eUnderlay = "/ip4/127.0.0.1/tcp/1634" + +// e2eConnecter resolves every underlay to the one broker of the test network. +type e2eConnecter struct { + addr *bzz.Address +} + +func (c *e2eConnecter) Connect(context.Context, []ma.Multiaddr) (*bzz.Address, error) { + return c.addr, nil +} + +// e2eNode is one Bee node of the test network: a client bps service on its own +// recorder, a bridge over it, and an API server serving the pubsub endpoint. +type e2eNode struct { + addr string + recorder *streamtest.Recorder +} + +// newE2EBroker returns a running broker service and the overlay it is reached +// at. The broker holds no streamer of its own — it only ever answers. +func newE2EBroker(t *testing.T, o bps.Options) (*bps.Service, swarm.Address) { + t.Helper() + + broker := bps.New(nil, log.Noop, o) + t.Cleanup(func() { + if err := broker.Close(); err != nil { + t.Fatal(err) + } + }) + return broker, swarm.MustParseHexAddress("ca11ab1e") +} + +// newE2ENode wires one node onto the broker. Every node gets a distinct base +// overlay, which is the address the broker sees the stream arrive from: the +// cohort keys its retained streams by peer, so two nodes sharing one base +// address would not be two peers. +func newE2ENode(t *testing.T, broker *bps.Service, brokerAddr, base swarm.Address) *e2eNode { + t.Helper() + + recorder := streamtest.New( + streamtest.WithProtocols(broker.Protocol()), + streamtest.WithBaseAddr(base), + ) + + client := bps.New(recorder, log.Noop, bps.Options{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatal(err) + } + }) + + underlay, err := ma.NewMultiaddr(e2eUnderlay) + if err != nil { + t.Fatal(err) + } + bridge := bps.NewBridge(client, &e2eConnecter{addr: &bzz.Address{ + Underlays: []ma.Multiaddr{underlay}, + Overlay: brokerAddr, + }}, log.Noop) + t.Cleanup(func() { + if err := bridge.Close(); err != nil { + t.Fatal(err) + } + }) + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + return &e2eNode{addr: addr, recorder: recorder} +} + +// anchorFrame assembles an ANCHOR publish frame: sig(65) ‖ span(8) ‖ payload. +func anchorFrame(sc *soc.SOC) []byte { + frame := make([]byte, 0, swarm.SocSignatureSize+len(sc.WrappedChunk().Data())) + frame = append(frame, sc.Signature()...) + return append(frame, sc.WrappedChunk().Data()...) +} + +// feedFrame assembles a FEED_TOPIC publish frame: index(8 BE) ‖ sig ‖ span ‖ +// payload. The index is not on the wire between nodes; it is how the +// publisher's own node re-derives the SOC id. +func feedFrame(index uint64, sc *soc.SOC) []byte { + return append(binary.BigEndian.AppendUint64(nil, index), anchorFrame(sc)...) +} + +// expectPayload reads binary frames until one carries want, or the deadline +// passes. A publisher's own socket also sees what it published, so a test +// looking for one payload has to be willing to skip others. +func expectPayload(t *testing.T, conn *websocket.Conn, want []byte) { + t.Helper() + + deadline := time.Now().Add(10 * time.Second) + if err := conn.SetReadDeadline(deadline); err != nil { + t.Fatal(err) + } + for { + mt, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("waiting for payload %q: %v", want, err) + } + if mt == websocket.BinaryMessage && bytes.Equal(data, want) { + return + } + } +} + +// readSocFields reads one JSON text frame of selected SOC fields. +func readSocFields(t *testing.T, conn *websocket.Conn) map[string]string { + t.Helper() + + if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatal(err) + } + mt, data, err := conn.ReadMessage() + if err != nil { + t.Fatal(err) + } + if mt != websocket.TextMessage { + t.Fatalf("message type: got %d want text", mt) + } + var out map[string]string + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestConformanceJamCohort(t *testing.T) { + t.Parallel() + + broker, brokerAddr := newE2EBroker(t, bps.Options{}) + nodeA := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("aa01")) + nodeB := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("bb02")) + nodeE := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("ee05")) + + signerA, ownerA := bpstesting.NewSigner(t) + signerB, ownerB := bpstesting.NewSigner(t) + _, ownerC := bpstesting.NewSigner(t) + _, ownerD := bpstesting.NewSigner(t) + + // The path segment is a mnemonic: the node hashes it into the topic, and + // the dApp holding the keys has to sign the same id. + const mnemonic = "jam-tuesday" + topic, err := crypto.LegacyKeccak256([]byte(mnemonic)) + if err != nil { + t.Fatal(err) + } + + // A opens the closed, list-publisher cohort as its admin. + qa := url.Values{} + qa.Set("peer", e2eUnderlay) + qa.Set("binding", "anchor") + qa.Set("publishers", "list") + qa.Set("closed", "true") + qa.Set("admin", hex.EncodeToString(ownerA)) + qa.Set("publisher-list", hex.EncodeToString(ownerB)+","+hex.EncodeToString(ownerC)+","+hex.EncodeToString(ownerD)) + qa.Set("owner", hex.EncodeToString(ownerA)) + + connA, _, err := dialBpsWs(t, nodeA.addr, "/pubsub/"+mnemonic+"?"+qa.Encode(), nil) + if err != nil { + t.Fatalf("admin dial: %v", err) + } + defer connA.Close() + + // B joins the live cohort from another node, naming only its owner: no + // cohort parameters means Subscribe, and the owner makes it a publisher. + qb := url.Values{} + qb.Set("peer", e2eUnderlay) + qb.Set("owner", hex.EncodeToString(ownerB)) + + connB, _, err := dialBpsWs(t, nodeB.addr, "/pubsub/"+mnemonic+"?"+qb.Encode(), nil) + if err != nil { + t.Fatalf("member dial: %v", err) + } + defer connB.Close() + + // A publishes, B receives. + fromA := []byte("scones at three") + scA := bpstesting.AnchorSOC(t, signerA, topic, fromA) + if err := connA.WriteMessage(websocket.BinaryMessage, anchorFrame(scA)); err != nil { + t.Fatal(err) + } + expectPayload(t, connB, fromA) + + // B publishes, A receives. + fromB := []byte("bring the clotted cream") + scB := bpstesting.AnchorSOC(t, signerB, topic, fromB) + if err := connB.WriteMessage(websocket.BinaryMessage, anchorFrame(scB)); err != nil { + t.Fatal(err) + } + expectPayload(t, connA, fromB) + + // A fifth client on a node that has no session for the topic, presenting + // no owner, is refused admission to the closed cohort. + qe := url.Values{} + qe.Set("peer", e2eUnderlay) + + _, resp, err := dialBpsWs(t, nodeE.addr, "/pubsub/"+mnemonic+"?"+qe.Encode(), nil) + if err == nil { + t.Fatal("outsider was admitted to a closed cohort") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("outsider status: got %d want %d", resp.StatusCode, http.StatusForbidden) + } +} + +func TestConformanceLiveStream(t *testing.T) { + t.Parallel() + + broker, brokerAddr := newE2EBroker(t, bps.Options{}) + pubNode := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("aa11")) + subNode := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("bb12")) + + signerP, ownerP := bpstesting.NewSigner(t) + topic := swarm.RandAddress(t) + + qp := url.Values{} + qp.Set("peer", e2eUnderlay) + qp.Set("binding", "feed") + qp.Set("publishers", "single") + qp.Set("admin", hex.EncodeToString(ownerP)) + qp.Set("owner", hex.EncodeToString(ownerP)) + + pubConn, _, err := dialBpsWs(t, pubNode.addr, "/pubsub/"+topic.String()+"?"+qp.Encode(), nil) + if err != nil { + t.Fatalf("publisher dial: %v", err) + } + defer pubConn.Close() + + qs := url.Values{} + qs.Set("peer", e2eUnderlay) + + header := http.Header{api.SwarmSocFieldsHeader: {"identifier,payload"}} + subConn, _, err := dialBpsWs(t, subNode.addr, "/pubsub/"+topic.String()+"?"+qs.Encode(), header) + if err != nil { + t.Fatalf("subscriber dial: %v", err) + } + defer subConn.Close() + + payloads := [][]byte{[]byte("frame zero"), []byte("frame one")} + frames := make([][]byte, len(payloads)) + for i, p := range payloads { + sc := bpstesting.FeedSOC(t, signerP, topic.Bytes(), uint64(i), p) + frames[i] = feedFrame(uint64(i), sc) + if err := pubConn.WriteMessage(websocket.BinaryMessage, frames[i]); err != nil { + t.Fatal(err) + } + } + + for i, p := range payloads { + got := readSocFields(t, subConn) + + id, err := bps.FeedID(topic.Bytes(), uint64(i)) + if err != nil { + t.Fatal(err) + } + if got["identifier"] != hex.EncodeToString(id) { + t.Fatalf("index %d identifier: got %s want %s", i, got["identifier"], hex.EncodeToString(id)) + } + if got["payload"] != hex.EncodeToString(p) { + t.Fatalf("index %d payload: got %s want %s", i, got["payload"], hex.EncodeToString(p)) + } + } + + // Republishing index 0 verbatim is deduplicated by the broker, so no third + // message reaches the subscriber. A fresh index 2 published straight after + // it is the drain marker: ordering is preserved end to end — the broker + // reads one publisher's stream serially and enqueues fan-out in order — so + // if the duplicate had been rebroadcast it would be sitting ahead of index + // 2 in the subscriber's queue. Asserting that the *next* message read is + // index 2 therefore cannot pass merely because the network was slow, which + // a bare read deadline could. + if err := pubConn.WriteMessage(websocket.BinaryMessage, frames[0]); err != nil { + t.Fatal(err) + } + marker := bpstesting.FeedSOC(t, signerP, topic.Bytes(), 2, []byte("frame two")) + if err := pubConn.WriteMessage(websocket.BinaryMessage, feedFrame(2, marker)); err != nil { + t.Fatal(err) + } + + got := readSocFields(t, subConn) + markerID, err := bps.FeedID(topic.Bytes(), 2) + if err != nil { + t.Fatal(err) + } + if got["identifier"] != hex.EncodeToString(markerID) { + t.Fatalf("message after the duplicate: got identifier %s want %s (the duplicate was rebroadcast)", + got["identifier"], hex.EncodeToString(markerID)) + } + + // Secondary check: nothing at all trails the marker. + if err := subConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatal(err) + } + if _, data, err := subConn.ReadMessage(); err == nil { + t.Fatalf("unexpected message after the drain marker: %q", data) + } +} + +func TestConformanceFull(t *testing.T) { + t.Parallel() + + broker, brokerAddr := newE2EBroker(t, bps.Options{Capacity: 1}) + nodeA := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("aa21")) + nodeB := newE2ENode(t, broker, brokerAddr, swarm.MustParseHexAddress("bb22")) + + _, ownerP := bpstesting.NewSigner(t) + topic := swarm.RandAddress(t) + + qa := url.Values{} + qa.Set("peer", e2eUnderlay) + qa.Set("binding", "anchor") + qa.Set("publishers", "single") + qa.Set("admin", hex.EncodeToString(ownerP)) + qa.Set("owner", hex.EncodeToString(ownerP)) + + connA, _, err := dialBpsWs(t, nodeA.addr, "/pubsub/"+topic.String()+"?"+qa.Encode(), nil) + if err != nil { + t.Fatalf("first dial: %v", err) + } + defer connA.Close() + + // The single stream slot is taken, so the second node's Subscribe is + // refused with FULL, which the API answers as 503. + qb := url.Values{} + qb.Set("peer", e2eUnderlay) + + _, resp, err := dialBpsWs(t, nodeB.addr, "/pubsub/"+topic.String()+"?"+qb.Encode(), nil) + if err == nil { + t.Fatal("second session was admitted to a full cohort") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusServiceUnavailable) + } + + // At the wire level the refused stream carried exactly one Hello and one + // Ack: a refusal is answered and reset, never referred or half served. + // Node B has its own recorder, so only its own refused stream is listed. + records, err := nodeB.recorder.Records(brokerAddr, bps.ProtocolName, bps.ProtocolVersion, bps.StreamName) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("streams to the broker: got %d want 1", len(records)) + } + + in := protobuf.NewReader(bytes.NewReader(records[0].In())) + var hello pb.Hello + if err := in.ReadMsg(&hello); err != nil { + t.Fatal(err) + } + if err := in.ReadMsg(&hello); !errors.Is(err, io.EOF) { + t.Fatalf("client wrote more than one Hello: %v", err) + } + + out := protobuf.NewReader(bytes.NewReader(records[0].Out())) + var ack pb.Ack + if err := out.ReadMsg(&ack); err != nil { + t.Fatal(err) + } + if ack.Status != pb.Status_FULL { + t.Fatalf("ack status: got %s want FULL", ack.Status) + } + if err := out.ReadMsg(&ack); !errors.Is(err, io.EOF) { + t.Fatalf("broker wrote more than the refusal Ack: %v", err) + } +} diff --git a/pkg/api/bps_test.go b/pkg/api/bps_test.go new file mode 100644 index 00000000000..a20826eeade --- /dev/null +++ b/pkg/api/bps_test.go @@ -0,0 +1,264 @@ +// 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/binary" + "encoding/json" + "testing" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/gorilla/websocket" +) + +func TestBpsFrameAnchorRoundTrip(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + payload := []byte("anchor payload") + + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + wantAddr, err := sc.Address() + if err != nil { + t.Fatal(err) + } + + frame := append(append([]byte{}, sc.Signature()...), sc.WrappedChunk().Data()...) + + got, err := api.ParsePublishFrame(pb.TopicBinding_ANCHOR, topic, owner, frame) + if err != nil { + t.Fatal(err) + } + gotAddr, err := got.Address() + if err != nil { + t.Fatal(err) + } + if !gotAddr.Equal(wantAddr) { + t.Fatalf("address mismatch: got %s want %s", gotAddr, wantAddr) + } +} + +func TestBpsFrameFeedTopicRoundTrip(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + payload := []byte("feed payload") + const index = uint64(7) + + sc := bpstesting.FeedSOC(t, signer, topic.Bytes(), index, payload) + wantAddr, err := sc.Address() + if err != nil { + t.Fatal(err) + } + + indexBytes := make([]byte, 8) + binary.BigEndian.PutUint64(indexBytes, index) + frame := append(indexBytes, sc.Signature()...) + frame = append(frame, sc.WrappedChunk().Data()...) + + got, err := api.ParsePublishFrame(pb.TopicBinding_FEED_TOPIC, topic, owner, frame) + if err != nil { + t.Fatal(err) + } + gotAddr, err := got.Address() + if err != nil { + t.Fatal(err) + } + if !gotAddr.Equal(wantAddr) { + t.Fatalf("address mismatch: got %s want %s", gotAddr, wantAddr) + } +} + +func TestBpsFrameTruncatedRejected(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), []byte("data")) + + frame := append(append([]byte{}, sc.Signature()...), sc.WrappedChunk().Data()...) + + // Truncate down to something shorter than sig+span to be unambiguous. + short := frame[:swarm.SocSignatureSize+swarm.SpanSize-1] + + if _, err := api.ParsePublishFrame(pb.TopicBinding_ANCHOR, topic, owner, short); err == nil { + t.Fatal("expected error for truncated frame") + } +} + +func TestBpsFrameBadSignatureRejected(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), []byte("data")) + + frame := append(append([]byte{}, sc.Signature()...), sc.WrappedChunk().Data()...) + // Corrupt one byte of the signature. + frame[0] ^= 0xff + + if _, err := api.ParsePublishFrame(pb.TopicBinding_ANCHOR, topic, owner, frame); err == nil { + t.Fatal("expected error for bad signature") + } +} + +func TestBpsFrameOversizedPayloadRejected(t *testing.T) { + t.Parallel() + + _, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + + // A well-formed frame shape (sig + span + payload) but with a payload + // larger than swarm.ChunkSize; the signature and span contents don't + // need to be valid since the size check must reject it first. + sig := make([]byte, swarm.SocSignatureSize) + span := make([]byte, swarm.SpanSize) + payload := make([]byte, swarm.ChunkSize+1) + frame := append(append(sig, span...), payload...) + + if _, err := api.ParsePublishFrame(pb.TopicBinding_ANCHOR, topic, owner, frame); err == nil { + t.Fatal("expected error for oversized payload") + } +} + +func TestBpsFrameUnsupportedBindingRejected(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), []byte("data")) + + frame := append(append([]byte{}, sc.Signature()...), sc.WrappedChunk().Data()...) + + if _, err := api.ParsePublishFrame(pb.TopicBinding_OWNER, topic, owner, frame); err == nil { + t.Fatal("expected error for unsupported binding") + } +} + +func TestBpsSocFieldsDefault(t *testing.T) { + t.Parallel() + + f, err := api.ParseSocFields("") + if err != nil { + t.Fatal(err) + } + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + payload := []byte("default field selection") + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + + msgType, data, err := api.SerializeSoc(f, sc) + if err != nil { + t.Fatal(err) + } + if msgType != websocket.BinaryMessage { + t.Fatalf("got msgType %d want BinaryMessage (default should be payload-only)", msgType) + } + if !bytes.Equal(data, payload) { + t.Fatalf("got %x want %x", data, payload) + } +} + +func TestBpsSocFieldsParsesKnown(t *testing.T) { + t.Parallel() + + f, err := api.ParseSocFields(" identifier , payload ") + if err != nil { + t.Fatal(err) + } + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), []byte("data")) + + msgType, data, err := api.SerializeSoc(f, sc) + if err != nil { + t.Fatal(err) + } + if msgType != websocket.TextMessage { + t.Fatalf("got msgType %d want TextMessage", msgType) + } + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if len(m) != 2 { + t.Fatalf("got %d keys, want exactly 2: %+v", len(m), m) + } +} + +func TestBpsSocFieldsUnknownRejected(t *testing.T) { + t.Parallel() + + if _, err := api.ParseSocFields("bogus"); err == nil { + t.Fatal("expected error for unknown field") + } +} + +func TestBpsSerializePayloadOnlyBinary(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + payload := []byte("hello world") + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + + f, err := api.ParseSocFields("") + if err != nil { + t.Fatal(err) + } + msgType, data, err := api.SerializeSoc(f, sc) + if err != nil { + t.Fatal(err) + } + if msgType != websocket.BinaryMessage { + t.Fatalf("got msgType %d want BinaryMessage", msgType) + } + if !bytes.Equal(data, payload) { + t.Fatalf("got %x want %x", data, payload) + } +} + +func TestBpsSerializeIdentifierPayloadJSON(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.NewAddress(swarm.RandAddress(t).Bytes()) + payload := []byte("hello world") + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + + f, err := api.ParseSocFields("identifier,payload") + if err != nil { + t.Fatal(err) + } + msgType, data, err := api.SerializeSoc(f, sc) + if err != nil { + t.Fatal(err) + } + if msgType != websocket.TextMessage { + t.Fatalf("got msgType %d want TextMessage", msgType) + } + + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if len(m) != 2 { + t.Fatalf("got %d keys, want exactly 2: %+v", len(m), m) + } + if _, ok := m["identifier"]; !ok { + t.Fatal("missing identifier key") + } + if _, ok := m["payload"]; !ok { + t.Fatal("missing payload key") + } +} diff --git a/pkg/api/bps_ws.go b/pkg/api/bps_ws.go new file mode 100644 index 00000000000..551fbb0ed3e --- /dev/null +++ b/pkg/api/bps_ws.go @@ -0,0 +1,528 @@ +// 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 ( + "context" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + ma "github.com/multiformats/go-multiaddr" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// defaultBpsKeepAlive is the ping period of a pubsub websocket session when +// the client does not name one with Swarm-Keep-Alive. +const defaultBpsKeepAlive = 60 * time.Second + +// bpsSession is everything the pump and reader goroutines of one pubsub +// websocket session need, assembled by the handler before the upgrade. +type bpsSession struct { + att bps.Attachment + topic swarm.Address + owner []byte + binding pb.TopicBinding + fields socFields + cacheWrapped bool + keepAlive time.Duration +} + +// bpsWsHandler upgrades a client onto one topic of the pubsub bridge. Every +// parameter is parsed, and the bridge attach performed, before the upgrade, so +// a bad request is answered as an HTTP error rather than as a websocket that +// closes immediately. +func (s *Service) bpsWsHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("bps_subscribe").Build() + + if s.bps == nil { + jsonhttp.NotImplemented(w, "pubsub not enabled") + return + } + + topic, err := bpsResolveTopic(mux.Vars(r)["topic"]) + if err != nil { + logger.Debug("parse topic failed", "error", err) + jsonhttp.BadRequest(w, "invalid topic") + return + } + + q := r.URL.Query() + + peer, err := ma.NewMultiaddr(q.Get("peer")) + if err != nil { + logger.Debug("parse peer failed", "error", err) + jsonhttp.BadRequest(w, "invalid peer multiaddr") + return + } + + spec, err := bpsSpecFromQuery(topic, q) + if err != nil { + logger.Debug("assemble cohort spec failed", "error", err) + jsonhttp.BadRequest(w, "invalid cohort parameters") + return + } + + var owner []byte + if v := q.Get("owner"); v != "" { + owner, err = bpsParseAddress(v) + if err != nil { + logger.Debug("parse owner failed", "error", err) + jsonhttp.BadRequest(w, "invalid owner") + return + } + } + + keepAlive := defaultBpsKeepAlive + if s.WsPingPeriod > 0 { + keepAlive = s.WsPingPeriod + } + if v := r.Header.Get(SwarmKeepAliveHeader); v != "" { + secs, err := strconv.Atoi(v) + if err != nil || secs <= 0 { + logger.Debug("parse keep alive failed", "value", v, "error", err) + jsonhttp.BadRequest(w, "invalid "+SwarmKeepAliveHeader) + return + } + keepAlive = time.Duration(secs) * time.Second + } + + fields, err := parseSocFields(r.Header.Get(SwarmSocFieldsHeader)) + if err != nil { + logger.Debug("parse soc fields failed", "error", err) + jsonhttp.BadRequest(w, "invalid "+SwarmSocFieldsHeader) + return + } + + var cacheWrapped bool + if v := r.Header.Get(SwarmCacheWrappedChunkHeader); v != "" { + cacheWrapped, err = strconv.ParseBool(v) + if err != nil { + logger.Debug("parse cache wrapped chunk failed", "error", err) + jsonhttp.BadRequest(w, "invalid "+SwarmCacheWrappedChunkHeader) + return + } + } + + att, err := s.bps.Attach(r.Context(), bps.AttachOptions{ + Peer: peer, + Topic: topic, + Spec: spec, + Owner: owner, + }) + if err != nil { + logger.Debug("attach failed", "topic", topic, "error", err) + s.bpsAttachError(w, err) + return + } + + // The binding decides how an inbound publish frame is parsed. The live + // session's spec wins over the requested one: a subscriber-turned-publisher + // brings no spec at all, and a session that is already open is authoritative. + binding := pb.TopicBinding_ANCHOR + if live := att.Spec(); live != nil { + binding = live.GetBinding() + } else if spec != nil { + binding = spec.GetBinding() + } + + upgrader := websocket.Upgrader{ + ReadBufferSize: swarm.ChunkSize, + WriteBufferSize: swarm.ChunkSize, + CheckOrigin: s.checkOrigin, + } + + // Counted before the upgrade, not after: the client's dial returns as soon + // as the handshake response is written, so a shutdown that starts right + // then would race Add against wsWg.Wait and could miss this session. + s.wsWg.Add(1) + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + s.wsWg.Done() + logger.Debug("upgrade failed", "error", err) + logger.Error(nil, "upgrade failed") + _ = att.Close() + jsonhttp.InternalServerError(w, "upgrade failed") + return + } + + go s.bpsPumpWs(conn, &bpsSession{ + att: att, + topic: topic, + owner: owner, + binding: binding, + fields: fields, + cacheWrapped: cacheWrapped, + keepAlive: keepAlive, + }) +} + +// bpsAttachError answers an attach failure. A spec that disagrees with the +// live session is 409 rather than 403: nothing about the client is refused, +// the request conflicts with state the node already holds, and the client can +// retry with the session's own spec. +func (s *Service) bpsAttachError(w http.ResponseWriter, err error) { + var refusal *bps.RefusalError + switch { + case errors.As(err, &refusal): + switch refusal.Status { + case pb.Status_FULL: + jsonhttp.ServiceUnavailable(w, "cohort full") + case pb.Status_UNKNOWN_TOPIC: + jsonhttp.NotFound(w, "unknown topic") + case pb.Status_REJECTED: + jsonhttp.Forbidden(w, "broker rejected the handshake") + default: + jsonhttp.InternalServerError(w, "attach failed") + } + case errors.Is(err, bps.ErrSpecMismatch): + jsonhttp.Conflict(w, "cohort spec mismatch") + case errors.Is(err, bps.ErrNotPublisher): + jsonhttp.Forbidden(w, "not a publisher") + case errors.Is(err, bps.ErrNoPeer), + errors.Is(err, bps.ErrInvalidSpec), + errors.Is(err, bps.ErrUnsupportedBinding), + errors.Is(err, bps.ErrUnsupportedRegime): + jsonhttp.BadRequest(w, "invalid attach request") + default: + jsonhttp.InternalServerError(w, "attach failed") + } +} + +// bpsPumpWs writes the attachment's messages to the client until either side +// goes away. It owns the connection and the attachment from here on. +func (s *Service) bpsPumpWs(conn *websocket.Conn, ss *bpsSession) { + defer s.wsWg.Done() + + ctx, cancel := context.WithCancel(context.Background()) + + var ( + gone = make(chan struct{}) + once sync.Once + ticker = time.NewTicker(ss.keepAlive) + ) + closeGone := func() { once.Do(func() { close(gone) }) } + + defer func() { + cancel() + ticker.Stop() + _ = conn.Close() + _ = ss.att.Close() + }() + + conn.SetCloseHandler(func(code int, text string) error { + s.logger.Debug("bps ws: client gone", "code", code, "message", text) + closeGone() + return nil + }) + + // Only a publisher session reads: a subscriber has nothing to send, and a + // reader would only compete with the library's own control-frame handling. + if len(ss.owner) > 0 { + go s.bpsReadWs(ctx, conn, ss, closeGone) + } + + msgs := ss.att.Messages() + + for { + select { + case sc, ok := <-msgs: + if !ok { + // the bridge tore the session down + s.bpsWriteClose(conn) + return + } + + if ss.cacheWrapped { + if err := s.storer.Cache().Put(ctx, sc.WrappedChunk()); err != nil { + s.logger.Debug("bps ws: cache wrapped chunk failed", "error", err) + } + } + + msgType, data, err := serializeSoc(ss.fields, sc) + if err != nil { + s.logger.Debug("bps ws: serialize message failed", "error", err) + continue + } + + if err := conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + s.logger.Debug("bps ws: set write deadline failed", "error", err) + return + } + if err := conn.WriteMessage(msgType, data); err != nil { + s.logger.Debug("bps ws: write message failed", "error", err) + return + } + + case <-s.quit: + s.bpsWriteClose(conn) + return + + case <-gone: + return + + case <-ticker.C: + if err := conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + s.logger.Debug("bps ws: set write deadline failed", "error", err) + return + } + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + // client probably gone + return + } + } + } +} + +// bpsWriteClose sends a close frame, best effort. +func (s *Service) bpsWriteClose(conn *websocket.Conn) { + if err := conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + s.logger.Debug("bps ws: set write deadline failed", "error", err) + return + } + if err := conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { + s.logger.Debug("bps ws: write close message failed", "error", err) + } +} + +// bpsReadWs turns inbound binary frames into publishes. A malformed or refused +// frame is logged and skipped: the websocket carries no per-frame reply, so +// the alternative would be tearing the whole session down over one bad frame. +// It returns — and takes the session down with it — only on a read error, +// which is also how it exits once the pump closes the connection. +func (s *Service) bpsReadWs(ctx context.Context, conn *websocket.Conn, ss *bpsSession, closeGone func()) { + defer closeGone() + + // Cap the frame gorilla/websocket buffers before parsePublishFrame's own + // payload-size check ever runs, so an oversized client frame is rejected + // (ErrReadLimit, session torn down) rather than fully read into memory. + conn.SetReadLimit(feedIndexSize + swarm.SocSignatureSize + swarm.SpanSize + swarm.ChunkSize) + + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + s.logger.Debug("bps ws: read message failed", "error", err) + return + } + if msgType != websocket.BinaryMessage { + s.logger.Debug("bps ws: ignoring non-binary frame", "type", msgType) + continue + } + + sc, err := parsePublishFrame(ss.binding, ss.topic, ss.owner, data) + if err != nil { + s.logger.Debug("bps ws: parse publish frame failed", "error", err) + continue + } + if err := ss.att.Publish(ctx, sc); err != nil { + s.logger.Debug("bps ws: publish failed", "error", err) + continue + } + } +} + +// bpsCohortResponse is the cohort spec of one topic in the listing. +type bpsCohortResponse struct { + Binding string `json:"binding"` + Publishers string `json:"publishers"` + Admin string `json:"admin"` + PublisherList []string `json:"publisherList"` + Closed bool `json:"closed"` + History bool `json:"history"` +} + +// bpsTopicResponse is one entry of the GET /pubsub listing. +type bpsTopicResponse struct { + Topic string `json:"topic"` + Role string `json:"role"` + Peers int `json:"peers"` + Cohort *bpsCohortResponse `json:"cohort,omitempty"` +} + +// bpsTopicsHandler lists every topic this node participates in. +func (s *Service) bpsTopicsHandler(w http.ResponseWriter, r *http.Request) { + if s.bps == nil { + jsonhttp.NotImplemented(w, "pubsub not enabled") + return + } + + status := s.bps.Status() + out := make([]bpsTopicResponse, 0, len(status)) + for _, t := range status { + e := bpsTopicResponse{ + Topic: t.Topic.String(), + Role: "client", + Peers: t.Peers, + } + if t.Broker { + e.Role = "broker" + } + if t.Spec != nil { + list := make([]string, 0, len(t.Spec.GetPublisherList())) + for _, p := range t.Spec.GetPublisherList() { + list = append(list, hex.EncodeToString(p)) + } + e.Cohort = &bpsCohortResponse{ + Binding: bpsBindingName(t.Spec.GetBinding()), + Publishers: bpsRegimeName(t.Spec.GetPublishers()), + Admin: hex.EncodeToString(t.Spec.GetAdmin()), + PublisherList: list, + Closed: t.Spec.GetClosed(), + History: t.Spec.GetHistory(), + } + } + out = append(out, e) + } + + jsonhttp.OK(w, out) +} + +// bpsResolveTopic reads the {topic} path segment: 64 hex characters name the +// topic directly, anything else is a mnemonic hashed into one. +func bpsResolveTopic(raw string) (swarm.Address, error) { + if raw == "" { + return swarm.ZeroAddress, fmt.Errorf("bps: empty topic") + } + if len(raw) == swarm.HashSize*2 { + if b, err := hex.DecodeString(raw); err == nil { + return swarm.NewAddress(b), nil + } + } + h, err := crypto.LegacyKeccak256([]byte(raw)) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("bps: hash topic mnemonic: %w", err) + } + return swarm.NewAddress(h), nil +} + +// bpsSpecFromQuery assembles a cohort spec from the query, or returns nil when +// the request names no cohort parameter at all — which is a subscribe. +func bpsSpecFromQuery(topic swarm.Address, q map[string][]string) (*pb.CohortSpec, error) { + get := func(k string) string { + if v, ok := q[k]; ok && len(v) > 0 { + return v[0] + } + return "" + } + + var named bool + for _, k := range []string{"binding", "publishers", "admin", "publisher-list", "closed", "history"} { + if _, ok := q[k]; ok { + named = true + break + } + } + if !named { + return nil, nil + } + + spec := &pb.CohortSpec{Topic: topic.Bytes()} + + switch get("binding") { + case "anchor": + spec.Binding = pb.TopicBinding_ANCHOR + case "feed": + spec.Binding = pb.TopicBinding_FEED_TOPIC + case "": + default: + return nil, fmt.Errorf("bps: unknown binding %q", get("binding")) + } + + switch get("publishers") { + case "single": + spec.Publishers = pb.PublisherRegime_EXPLICIT_SINGLE + case "list": + spec.Publishers = pb.PublisherRegime_EXPLICIT_LIST + case "": + default: + return nil, fmt.Errorf("bps: unknown publisher regime %q", get("publishers")) + } + + if v := get("admin"); v != "" { + admin, err := bpsParseAddress(v) + if err != nil { + return nil, fmt.Errorf("bps: admin: %w", err) + } + spec.Admin = admin + } + + if v := get("publisher-list"); v != "" { + for _, p := range strings.Split(v, ",") { + addr, err := bpsParseAddress(strings.TrimSpace(p)) + if err != nil { + return nil, fmt.Errorf("bps: publisher list: %w", err) + } + spec.PublisherList = append(spec.PublisherList, addr) + } + } + + if v := get("closed"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("bps: closed: %w", err) + } + spec.Closed = b + } + + if v := get("history"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("bps: history: %w", err) + } + spec.History = b + } + + if err := bps.ValidateSpec(spec); err != nil { + return nil, err + } + return spec, nil +} + +// bpsParseAddress decodes a 20-byte hex ethereum address. +func bpsParseAddress(v string) ([]byte, error) { + b, err := hex.DecodeString(strings.TrimPrefix(v, "0x")) + if err != nil { + return nil, fmt.Errorf("bps: decode address: %w", err) + } + if len(b) != crypto.AddressSize { + return nil, fmt.Errorf("bps: address length %d", len(b)) + } + return b, nil +} + +func bpsBindingName(b pb.TopicBinding) string { + switch b { + case pb.TopicBinding_ANCHOR: + return "anchor" + case pb.TopicBinding_FEED_TOPIC: + return "feed" + default: + return strings.ToLower(b.String()) + } +} + +func bpsRegimeName(p pb.PublisherRegime) string { + switch p { + case pb.PublisherRegime_EXPLICIT_SINGLE: + return "single" + case pb.PublisherRegime_EXPLICIT_LIST: + return "list" + default: + return strings.ToLower(p.String()) + } +} diff --git a/pkg/api/bps_ws_test.go b/pkg/api/bps_ws_test.go new file mode 100644 index 00000000000..33f56afff29 --- /dev/null +++ b/pkg/api/bps_ws_test.go @@ -0,0 +1,632 @@ +// 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/hex" + "encoding/json" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" + "github.com/ethersphere/bee/v2/pkg/soc" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// fakeBpsAttachment is a scripted api.BpsBridge attachment backed by channels. +type fakeBpsAttachment struct { + spec *pb.CohortSpec + msgs chan *soc.SOC + + mu sync.Mutex + closed bool + pubC chan *soc.SOC +} + +func newFakeBpsAttachment(spec *pb.CohortSpec) *fakeBpsAttachment { + return &fakeBpsAttachment{ + spec: spec, + msgs: make(chan *soc.SOC, 4), + pubC: make(chan *soc.SOC, 4), + } +} + +func (a *fakeBpsAttachment) Spec() *pb.CohortSpec { return a.spec } +func (a *fakeBpsAttachment) Messages() <-chan *soc.SOC { return a.msgs } +func (a *fakeBpsAttachment) Publish(_ context.Context, s *soc.SOC) error { + a.pubC <- s + return nil +} + +func (a *fakeBpsAttachment) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + a.closed = true + return nil +} + +func (a *fakeBpsAttachment) isClosed() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.closed +} + +// fakeBpsBridge records the options of the last attach and answers with a +// scripted attachment or a scripted error. +type fakeBpsBridge struct { + att *fakeBpsAttachment + err error + status []bps.TopicStatus + + mu sync.Mutex + opts *bps.AttachOptions +} + +func (b *fakeBpsBridge) Attach(_ context.Context, o bps.AttachOptions) (bps.Attachment, error) { + b.mu.Lock() + cp := o + b.opts = &cp + b.mu.Unlock() + if b.err != nil { + return nil, b.err + } + return b.att, nil +} + +func (b *fakeBpsBridge) Status() []bps.TopicStatus { return b.status } + +func (b *fakeBpsBridge) attached() *bps.AttachOptions { + b.mu.Lock() + defer b.mu.Unlock() + return b.opts +} + +func dialBpsWs(t *testing.T, addr, path string, header http.Header) (*websocket.Conn, *http.Response, error) { + t.Helper() + + u := url.URL{Scheme: "ws", Host: addr, Path: path} + if p, q, ok := strings.Cut(path, "?"); ok { + u.Path, u.RawQuery = p, q + } + return websocket.DefaultDialer.Dial(u.String(), header) +} + +func TestBpsWsPublishSubscribe(t *testing.T) { + t.Parallel() + + signerA, ownerA := bpstesting.NewSigner(t) + _, ownerB := bpstesting.NewSigner(t) + _, ownerC := bpstesting.NewSigner(t) + + topic := swarm.RandAddress(t) + + att := newFakeBpsAttachment(&pb.CohortSpec{ + Topic: topic.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_LIST, + Admin: ownerA, + }) + bridge := &fakeBpsBridge{att: att} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + q := url.Values{} + q.Set("peer", "/ip4/127.0.0.1/tcp/1634") + q.Set("binding", "anchor") + q.Set("publishers", "list") + q.Set("closed", "true") + q.Set("admin", hex.EncodeToString(ownerA)) + q.Set("publisher-list", hex.EncodeToString(ownerB)+","+hex.EncodeToString(ownerC)) + q.Set("owner", hex.EncodeToString(ownerA)) + + u := url.URL{Scheme: "ws", Host: addr, Path: "/pubsub/" + topic.String(), RawQuery: q.Encode()} + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + o := bridge.attached() + if o == nil { + t.Fatal("bridge was not attached") + } + if o.Spec == nil { + t.Fatal("expected an assembled cohort spec") + } + if !bytes.Equal(o.Spec.GetTopic(), topic.Bytes()) { + t.Fatalf("spec topic: got %x want %x", o.Spec.GetTopic(), topic.Bytes()) + } + if o.Spec.GetBinding() != pb.TopicBinding_ANCHOR { + t.Fatalf("spec binding: got %v", o.Spec.GetBinding()) + } + if o.Spec.GetPublishers() != pb.PublisherRegime_EXPLICIT_LIST { + t.Fatalf("spec publishers: got %v", o.Spec.GetPublishers()) + } + if !o.Spec.GetClosed() { + t.Fatal("spec closed: got false want true") + } + if !bytes.Equal(o.Spec.GetAdmin(), ownerA) { + t.Fatalf("spec admin: got %x want %x", o.Spec.GetAdmin(), ownerA) + } + if len(o.Spec.GetPublisherList()) != 2 { + t.Fatalf("publisher list: got %d entries want 2", len(o.Spec.GetPublisherList())) + } + if !bytes.Equal(o.Owner, ownerA) { + t.Fatalf("owner: got %x want %x", o.Owner, ownerA) + } + if o.Peer == nil { + t.Fatal("peer multiaddr not passed through") + } + + // publish one anchor frame + payload := []byte("published payload") + sc := bpstesting.AnchorSOC(t, signerA, topic.Bytes(), payload) + frame := append(append([]byte{}, sc.Signature()...), sc.WrappedChunk().Data()...) + if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + t.Fatal(err) + } + + select { + case got := <-att.pubC: + if !bytes.Equal(got.ID(), topic.Bytes()) { + t.Fatalf("published soc id: got %x want %x", got.ID(), topic.Bytes()) + } + if !bytes.Equal(got.WrappedChunk().Data()[swarm.SpanSize:], payload) { + t.Fatal("published payload mismatch") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for publish") + } + + // receive one message + inPayload := []byte("inbound payload") + in := bpstesting.AnchorSOC(t, signerA, topic.Bytes(), inPayload) + att.msgs <- in + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + mt, data, err := conn.ReadMessage() + if err != nil { + t.Fatal(err) + } + if mt != websocket.BinaryMessage { + t.Fatalf("message type: got %d want binary", mt) + } + if !bytes.Equal(data, inPayload) { + t.Fatalf("got %q want %q", data, inPayload) + } +} + +func TestBpsWsSubscribeOnly(t *testing.T) { + t.Parallel() + + topic := swarm.RandAddress(t) + att := newFakeBpsAttachment(nil) + bridge := &fakeBpsBridge{att: att} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + conn, _, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + o := bridge.attached() + if o == nil { + t.Fatal("bridge was not attached") + } + if o.Spec != nil { + t.Fatalf("spec: got %v want nil", o.Spec) + } + if o.Owner != nil { + t.Fatalf("owner: got %x want nil", o.Owner) + } + if !o.Topic.Equal(topic) { + t.Fatalf("topic: got %s want %s", o.Topic, topic) + } +} + +func TestBpsWsRefusalMapping(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + err error + status int + }{ + {"full", &bps.RefusalError{Status: pb.Status_FULL}, http.StatusServiceUnavailable}, + {"unknown topic", &bps.RefusalError{Status: pb.Status_UNKNOWN_TOPIC}, http.StatusNotFound}, + {"rejected", &bps.RefusalError{Status: pb.Status_REJECTED}, http.StatusForbidden}, + {"no peer", bps.ErrNoPeer, http.StatusBadRequest}, + {"spec mismatch", bps.ErrSpecMismatch, http.StatusConflict}, + {"not publisher", bps.ErrNotPublisher, http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + topic := swarm.RandAddress(t) + bridge := &fakeBpsBridge{err: tc.err} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + _, resp, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", nil) + if err == nil { + t.Fatal("expected the handshake to be refused") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != tc.status { + t.Fatalf("status: got %d want %d", resp.StatusCode, tc.status) + } + }) + } +} + +func TestBpsWsBadParams(t *testing.T) { + t.Parallel() + + topic := swarm.RandAddress(t) + + for _, tc := range []struct { + name string + path string + }{ + {"missing peer", "/pubsub/" + topic.String()}, + {"invalid peer", "/pubsub/" + topic.String() + "?peer=notamultiaddr"}, + {"invalid binding", "/pubsub/" + topic.String() + "?peer=/ip4/127.0.0.1/tcp/1634&binding=bogus"}, + {"incomplete spec", "/pubsub/" + topic.String() + "?peer=/ip4/127.0.0.1/tcp/1634&binding=anchor"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + bridge := &fakeBpsBridge{att: newFakeBpsAttachment(nil)} + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + _, resp, err := dialBpsWs(t, addr, tc.path, nil) + if err == nil { + t.Fatal("expected the handshake to be refused") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusBadRequest) + } + }) + } +} + +func TestBpsNotEnabled(t *testing.T) { + t.Parallel() + + client, _, addr, _ := newTestServer(t, testServerOptions{Storer: mockstorer.New()}) + + t.Run("topics", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodGet, "/pubsub", http.StatusNotImplemented, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "pubsub not enabled", + Code: http.StatusNotImplemented, + }), + ) + }) + + t.Run("websocket", func(t *testing.T) { + topic := swarm.RandAddress(t) + + _, resp, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", nil) + if err == nil { + t.Fatal("expected the handshake to be refused") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusNotImplemented) + } + }) +} + +func TestBpsWsBadHeaders(t *testing.T) { + t.Parallel() + + topic := swarm.RandAddress(t) + + for _, tc := range []struct { + name string + header http.Header + }{ + {"zero keep alive", http.Header{api.SwarmKeepAliveHeader: {"0"}}}, + {"negative keep alive", http.Header{api.SwarmKeepAliveHeader: {"-1"}}}, + {"non numeric keep alive", http.Header{api.SwarmKeepAliveHeader: {"soon"}}}, + {"unknown soc field", http.Header{api.SwarmSocFieldsHeader: {"identifier,bogus"}}}, + {"non boolean cache wrapped chunk", http.Header{api.SwarmCacheWrappedChunkHeader: {"maybe"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + bridge := &fakeBpsBridge{att: newFakeBpsAttachment(nil)} + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + _, resp, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", tc.header) + if err == nil { + t.Fatal("expected the handshake to be refused") + } + if resp == nil { + t.Fatalf("no http response: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusBadRequest) + } + if bridge.attached() != nil { + t.Fatal("bridge was attached despite a bad header") + } + }) + } +} + +func TestBpsWsSocFieldsHeader(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.RandAddress(t) + att := newFakeBpsAttachment(nil) + bridge := &fakeBpsBridge{att: att} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + header := http.Header{ + api.SwarmSocFieldsHeader: {"identifier,payload"}, + api.SwarmKeepAliveHeader: {"120"}, + } + conn, _, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", header) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + payload := []byte("json framed payload") + att.msgs <- bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + msgType, data, err := conn.ReadMessage() + if err != nil { + t.Fatal(err) + } + if msgType != websocket.TextMessage { + t.Fatalf("message type: got %d want text", msgType) + } + + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if len(m) != 2 { + t.Fatalf("got %d keys want exactly 2: %+v", len(m), m) + } + if m["identifier"] != topic.String() { + t.Fatalf("identifier: got %q want %q", m["identifier"], topic.String()) + } + if m["payload"] != hex.EncodeToString(payload) { + t.Fatalf("payload: got %q want %q", m["payload"], hex.EncodeToString(payload)) + } +} + +func TestBpsWsCacheWrappedChunk(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + topic := swarm.RandAddress(t) + att := newFakeBpsAttachment(nil) + bridge := &fakeBpsBridge{att: att} + + storer := mockstorer.New() + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: storer, + Bps: bridge, + }) + + header := http.Header{api.SwarmCacheWrappedChunkHeader: {"true"}} + conn, _, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", header) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + payload := []byte("cached payload") + sc := bpstesting.AnchorSOC(t, signer, topic.Bytes(), payload) + wrapped := sc.WrappedChunk() + att.msgs <- sc + + // the message is still delivered + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if _, data, err := conn.ReadMessage(); err != nil { + t.Fatal(err) + } else if !bytes.Equal(data, payload) { + t.Fatalf("got %q want %q", data, payload) + } + + // the wrapped chunk reached the cache: the mock storer's Cache putter and + // its ChunkStore are the same store, so the Put is observable here. + ctx := context.Background() + for i := 0; i < 100; i++ { + got, err := storer.ChunkStore().Get(ctx, wrapped.Address()) + if err == nil { + if !bytes.Equal(got.Data(), wrapped.Data()) { + t.Fatal("cached chunk data mismatch") + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("wrapped chunk was not cached") +} + +func TestBpsWsMnemonicTopic(t *testing.T) { + t.Parallel() + + att := newFakeBpsAttachment(nil) + bridge := &fakeBpsBridge{att: att} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + conn, _, err := dialBpsWs(t, addr, "/pubsub/my-topic?peer=/ip4/127.0.0.1/tcp/1634", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + want, err := api.BpsResolveTopic("my-topic") + if err != nil { + t.Fatal(err) + } + o := bridge.attached() + if o == nil { + t.Fatal("bridge was not attached") + } + if !o.Topic.Equal(want) { + t.Fatalf("topic: got %s want %s", o.Topic, want) + } +} + +func TestBpsWsSessionEnd(t *testing.T) { + t.Parallel() + + topic := swarm.RandAddress(t) + att := newFakeBpsAttachment(nil) + bridge := &fakeBpsBridge{att: att} + + _, _, addr, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + conn, _, err := dialBpsWs(t, addr, "/pubsub/"+topic.String()+"?peer=/ip4/127.0.0.1/tcp/1634", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + // the broker ends the session + close(att.msgs) + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if _, _, err := conn.ReadMessage(); err == nil { + t.Fatal("expected the connection to be closed by the node") + } + + for i := 0; i < 100; i++ { + if att.isClosed() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("attachment was not closed") +} + +func TestBpsTopics(t *testing.T) { + t.Parallel() + + _, ownerA := bpstesting.NewSigner(t) + _, ownerB := bpstesting.NewSigner(t) + topic := swarm.RandAddress(t) + + bridge := &fakeBpsBridge{ + status: []bps.TopicStatus{ + { + Topic: topic, + Spec: &pb.CohortSpec{ + Topic: topic.Bytes(), + Binding: pb.TopicBinding_FEED_TOPIC, + Publishers: pb.PublisherRegime_EXPLICIT_LIST, + Admin: ownerA, + PublisherList: [][]byte{ownerB}, + Closed: true, + }, + Broker: true, + Peers: 3, + }, + }, + } + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: bridge, + }) + + jsonhttptest.Request(t, client, http.MethodGet, "/pubsub", http.StatusOK, + jsonhttptest.WithExpectedJSONResponse([]api.BpsTopicResponse{ + { + Topic: topic.String(), + Role: "broker", + Peers: 3, + Cohort: &api.BpsCohortResponse{ + Binding: "feed", + Publishers: "list", + Admin: hex.EncodeToString(ownerA), + PublisherList: []string{hex.EncodeToString(ownerB)}, + Closed: true, + History: false, + }, + }, + }), + ) +} + +func TestBpsTopicsEmpty(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Bps: &fakeBpsBridge{}, + }) + + jsonhttptest.Request(t, client, http.MethodGet, "/pubsub", http.StatusOK, + jsonhttptest.WithExpectedJSONResponse([]api.BpsTopicResponse{}), + ) +} diff --git a/pkg/api/export_test.go b/pkg/api/export_test.go index 5bda912a3e9..39babb029cf 100644 --- a/pkg/api/export_test.go +++ b/pkg/api/export_test.go @@ -139,3 +139,17 @@ func MapStructure(input, output any, hooks map[string]func(v string) (string, er func NewParseError(entry, value string, cause error) error { return newParseError(entry, value, cause) } + +type SocFields = socFields + +type ( + BpsTopicResponse = bpsTopicResponse + BpsCohortResponse = bpsCohortResponse +) + +var ( + ParsePublishFrame = parsePublishFrame + ParseSocFields = parseSocFields + SerializeSoc = serializeSoc + BpsResolveTopic = bpsResolveTopic +) diff --git a/pkg/api/router.go b/pkg/api/router.go index 941c63fc89e..52c18c4d228 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -370,6 +370,14 @@ func (s *Service) mountAPI() { "GET": http.HandlerFunc(s.pssWsHandler), }) + handle("/pubsub", jsonhttp.MethodHandler{ + "GET": web.ChainHandlers(web.FinalHandlerFunc(s.bpsTopicsHandler)), + }) + + handle("/pubsub/{topic}", jsonhttp.MethodHandler{ + "GET": http.HandlerFunc(s.bpsWsHandler), + }) + handle("/gsoc/subscribe/{address}", jsonhttp.MethodHandler{ "GET": web.ChainHandlers( web.FinalHandlerFunc(s.gsocWsHandler), diff --git a/pkg/bps/binding.go b/pkg/bps/binding.go new file mode 100644 index 00000000000..14e5b29466e --- /dev/null +++ b/pkg/bps/binding.go @@ -0,0 +1,106 @@ +// 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 bps + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/soc" +) + +// ErrNotQualified is returned when a single-owner chunk is not a legitimate +// message for the cohort under its topic binding. +var ErrNotQualified = errors.New("bps: soc does not qualify for the cohort") + +// binding decides which single-owner chunks are legitimate messages for a +// cohort, and how they are deduplicated. One implementation per +// pb.TopicBinding; SWIP-60's later bindings are added here without touching +// the broker. +type binding interface { + // qualifies reports whether s is a legitimate message for spec's cohort. + qualifies(spec *pb.CohortSpec, s *soc.SOC) error + // dedupKey returns the key under which s is deduplicated. + dedupKey(s *soc.SOC) ([]byte, error) +} + +// bindingFor returns the binding rules for b, or ErrUnsupportedBinding. +func bindingFor(b pb.TopicBinding) (binding, error) { + switch b { + case pb.TopicBinding_ANCHOR: + return anchorBinding{}, nil + case pb.TopicBinding_FEED_TOPIC: + return feedTopicBinding{}, nil + } + return nil, fmt.Errorf("binding %s: %w", b, ErrUnsupportedBinding) +} + +// anchorBinding implements SWIP-60's ANCHOR semantics: under the default +// publisher regime the topic is the full SOC address, so every message in the +// cohort shares one address. Under an explicit publisher regime (EXPLICIT_SINGLE +// or EXPLICIT_LIST) that constraint is relaxed — see qualifies — and the topic +// is a mere rendezvous: the SOC id is unconstrained and legitimacy comes from +// list membership instead. Dedup is on the wrapped content-addressed chunk — +// the guard against unsolicited republication of old SOCs — regardless of +// regime. It is sound only under the application-level requirement that +// payloads are distinct. +type anchorBinding struct{} + +func (anchorBinding) qualifies(spec *pb.CohortSpec, s *soc.SOC) error { + switch spec.GetPublishers() { + case pb.PublisherRegime_EXPLICIT_SINGLE, pb.PublisherRegime_EXPLICIT_LIST: + // SWIP-60: under explicit regimes the SOC id does no protocol work — + // legitimacy is list membership, checked separately — so the topic is + // a mere rendezvous and the address constraint does not apply. + return nil + } + addr, err := s.Address() + if err != nil { + return fmt.Errorf("soc address: %w", err) + } + if !bytes.Equal(addr.Bytes(), spec.GetTopic()) { + return fmt.Errorf("soc address %s is not the anchor: %w", addr, ErrNotQualified) + } + return nil +} + +func (anchorBinding) dedupKey(s *soc.SOC) ([]byte, error) { + wrapped := s.WrappedChunk() + if wrapped == nil { + return nil, fmt.Errorf("no wrapped chunk: %w", ErrNotQualified) + } + return wrapped.Address().Bytes(), nil +} + +// feedTopicBinding implements SWIP-60's FEED_TOPIC semantics under explicit +// publisher regimes: id = keccak256(topic ‖ index). The index is not on the +// wire and keccak is not invertible, so the broker cannot re-derive the id; +// its enforcement point is the publisher's own node (WS bridge), which is +// handed the bare index. The binding's protocol work here is its dedup rule: +// the full chunk address, unique per (owner, index). +type feedTopicBinding struct{} + +func (feedTopicBinding) qualifies(*pb.CohortSpec, *soc.SOC) error { return nil } + +func (feedTopicBinding) dedupKey(s *soc.SOC) ([]byte, error) { + addr, err := s.Address() + if err != nil { + return nil, fmt.Errorf("soc address: %w", err) + } + return addr.Bytes(), nil +} + +// FeedID derives the SOC id of a feed-topic message: keccak256(topic ‖ index), +// index encoded as 8-byte big-endian, matching pkg/feeds/sequence. +func FeedID(topic []byte, index uint64) ([]byte, error) { + buf := make([]byte, 0, len(topic)+8) + buf = append(buf, topic...) + buf = binary.BigEndian.AppendUint64(buf, index) + return crypto.LegacyKeccak256(buf) +} diff --git a/pkg/bps/binding_test.go b/pkg/bps/binding_test.go new file mode 100644 index 00000000000..9aa892642bf --- /dev/null +++ b/pkg/bps/binding_test.go @@ -0,0 +1,360 @@ +// 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 bps_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/ethersphere/bee/v2/pkg/util/testutil" +) + +func TestAnchorBindingQualifies(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + id := topic(0x11) + s := bpstesting.AnchorSOC(t, signer, id, []byte("anchored")) + anchor, err := s.Address() + if err != nil { + t.Fatal(err) + } + + spec := &pb.CohortSpec{ + Topic: anchor.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + Admin: owner, + } + + b, err := bps.BindingFor(pb.TopicBinding_ANCHOR) + if err != nil { + t.Fatal(err) + } + if err := b.Qualifies(spec, s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Under explicit regimes the SOC id does no protocol work — legitimacy is + // list membership, checked separately — so a SOC under a different id + // still qualifies for this topic. + other := bpstesting.AnchorSOC(t, signer, topic(0x12), []byte("anchored")) + if err := b.Qualifies(spec, other); err != nil { + t.Fatalf("unexpected error under explicit regime: %v", err) + } +} + +// TestAnchorBindingStrictAddressCheck pins the address-equals-topic check +// that still applies outside the explicit publisher regimes. No such regime +// is implemented yet (SWIP-60's future IMPLICIT regime), so this exercises +// the binding directly rather than through ValidateSpec. +func TestAnchorBindingStrictAddressCheck(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + id := topic(0x13) + s := bpstesting.AnchorSOC(t, signer, id, []byte("anchored")) + anchor, err := s.Address() + if err != nil { + t.Fatal(err) + } + + spec := &pb.CohortSpec{ + Topic: anchor.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_IMPLICIT, + } + + b, err := bps.BindingFor(pb.TopicBinding_ANCHOR) + if err != nil { + t.Fatal(err) + } + if err := b.Qualifies(spec, s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // A SOC under a different id has a different address, so it does not + // belong to this anchor cohort. + other := bpstesting.AnchorSOC(t, signer, topic(0x14), []byte("anchored")) + if err := b.Qualifies(spec, other); !errors.Is(err, bps.ErrNotQualified) { + t.Fatalf("got %v want %v", err, bps.ErrNotQualified) + } +} + +// TestAnchorMnemonicExplicitList pins SWIP-60's relaxation of the ANCHOR +// binding under explicit publisher regimes: a mnemonic topic shared by +// multiple listed publishers can never satisfy the SOC-address-equals-topic +// check, so that check must not apply when publishers are explicit. +func TestAnchorMnemonicExplicitList(t *testing.T) { + t.Parallel() + + topic := swarm.NewAddress(testutil.RandBytes(t, swarm.HashSize)) + sA, ownerA := bpstesting.NewSigner(t) + sB, ownerB := bpstesting.NewSigner(t) + + spec := &pb.CohortSpec{ + Topic: topic.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_LIST, + Admin: ownerA, + PublisherList: [][]byte{ownerB}, + } + if err := bps.ValidateSpec(spec); err != nil { + t.Fatal(err) + } + + b, err := bps.BindingFor(pb.TopicBinding_ANCHOR) + if err != nil { + t.Fatal(err) + } + + msgA := bpstesting.AnchorSOC(t, sA, topic.Bytes(), []byte("seat A says hi")) + msgB := bpstesting.AnchorSOC(t, sB, topic.Bytes(), []byte("seat B says hi")) + + for _, m := range []*soc.SOC{msgA, msgB} { + if err := b.Qualifies(spec, m); err != nil { + t.Fatalf("mnemonic-anchor message must qualify: %v", err) + } + } + + // Dedup keys of two distinct payloads must differ... + ka, err := b.DedupKey(msgA) + if err != nil { + t.Fatal(err) + } + kb, err := b.DedupKey(msgB) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(ka, kb) { + t.Fatal("expected different dedup keys for different payloads") + } + + // ...but two SOCs wrapping the same CAC must collide on dedup key. + msgA2 := bpstesting.AnchorSOC(t, sB, topic.Bytes(), []byte("seat A says hi")) + if err := b.Qualifies(spec, msgA2); err != nil { + t.Fatalf("mnemonic-anchor message must qualify: %v", err) + } + ka2, err := b.DedupKey(msgA2) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(ka, ka2) { + t.Fatal("expected identical dedup keys for identical wrapped payloads") + } +} + +func TestAnchorBindingDedupKey(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + b, err := bps.BindingFor(pb.TopicBinding_ANCHOR) + if err != nil { + t.Fatal(err) + } + + // Same payload under different ids: same wrapped CAC, so same dedup key. + a := bpstesting.AnchorSOC(t, signer, topic(0x21), []byte("same payload")) + c := bpstesting.AnchorSOC(t, signer, topic(0x22), []byte("same payload")) + d := bpstesting.AnchorSOC(t, signer, topic(0x21), []byte("other payload")) + + ka, err := b.DedupKey(a) + if err != nil { + t.Fatal(err) + } + kc, err := b.DedupKey(c) + if err != nil { + t.Fatal(err) + } + kd, err := b.DedupKey(d) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(ka, kc) { + t.Fatal("expected identical dedup keys for identical payloads") + } + if bytes.Equal(ka, kd) { + t.Fatal("expected different dedup keys for different payloads") + } + if len(ka) != swarm.HashSize { + t.Fatalf("dedup key length: got %d want %d", len(ka), swarm.HashSize) + } +} + +func TestBindingForUnsupported(t *testing.T) { + t.Parallel() + + for _, b := range []pb.TopicBinding{ + pb.TopicBinding_TOPIC_BINDING_UNSPECIFIED, + pb.TopicBinding_SOC_ID, + pb.TopicBinding_OWNER, + } { + if _, err := bps.BindingFor(b); !errors.Is(err, bps.ErrUnsupportedBinding) { + t.Fatalf("binding %s: got %v want %v", b, err, bps.ErrUnsupportedBinding) + } + } +} + +// TestFeedTopicBinding pins SWIP-60's FEED_TOPIC semantics: a spec using it +// validates under an explicit publisher regime, FeedSOC derives its id via +// FeedID, and dedup keys are distinct across indices but collide for +// identical SOCs. +func TestFeedTopicBinding(t *testing.T) { + t.Parallel() + + feedTopic := testutil.RandBytes(t, swarm.HashSize) + signer, owner := bpstesting.NewSigner(t) + spec := &pb.CohortSpec{ + Topic: feedTopic, + Binding: pb.TopicBinding_FEED_TOPIC, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + Admin: owner, + } + if err := bps.ValidateSpec(spec); err != nil { + t.Fatalf("feed-topic explicit-single spec must validate: %v", err) + } + + m0 := bpstesting.FeedSOC(t, signer, feedTopic, 0, []byte("frame 0")) + m1 := bpstesting.FeedSOC(t, signer, feedTopic, 1, []byte("frame 1")) + + id0, err := bps.FeedID(feedTopic, 0) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(m0.ID(), id0) { + t.Fatal("FeedSOC id must be FeedID(topic, index)") + } + + b, err := bps.BindingFor(pb.TopicBinding_FEED_TOPIC) + if err != nil { + t.Fatal(err) + } + if err := b.Qualifies(spec, m0); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + k0, err := b.DedupKey(m0) + if err != nil { + t.Fatal(err) + } + k1, err := b.DedupKey(m1) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(k0, k1) { + t.Fatal("distinct indices must not collide on dedup key") + } + + m0Again := bpstesting.FeedSOC(t, signer, feedTopic, 0, []byte("frame 0")) + k0Again, err := b.DedupKey(m0Again) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(k0, k0Again) { + t.Fatal("identical soc must collide on dedup key") + } +} + +// TestFeedIDIndependentOracle checks FeedID against an oracle assembled by +// hand in this test, independently of FeedID's own byte-appending logic: +// the index is written out as eight literal big-endian bytes rather than via +// binary.BigEndian.AppendUint64, so a wrong endianness, operand order, or +// hash function in FeedID would be caught rather than silently agreeing with +// itself (unlike comparing FeedID against FeedSOC, which internally calls +// FeedID and so shares its code path). +func TestFeedIDIndependentOracle(t *testing.T) { + t.Parallel() + + feedTopic := testutil.RandBytes(t, swarm.HashSize) + + // index = 42, hand-written as 8 big-endian bytes: 0x00,...,0x00,0x2a. + want := append(append([]byte{}, feedTopic...), 0, 0, 0, 0, 0, 0, 0, 42) + oracle, err := crypto.LegacyKeccak256(want) + if err != nil { + t.Fatal(err) + } + + got, err := bps.FeedID(feedTopic, 42) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, oracle) { + t.Fatalf("FeedID(topic, 42) = %x, want %x (independent oracle)", got, oracle) + } + + // A second, independently-chosen vector at a different index, to guard + // against an oracle that happens to match only by coincidence. + want2 := append(append([]byte{}, feedTopic...), 0, 0, 0, 0, 0, 0, 1, 0) + oracle2, err := crypto.LegacyKeccak256(want2) + if err != nil { + t.Fatal(err) + } + got2, err := bps.FeedID(feedTopic, 256) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got2, oracle2) { + t.Fatalf("FeedID(topic, 256) = %x, want %x (independent oracle)", got2, oracle2) + } + if bytes.Equal(got, got2) { + t.Fatal("distinct indices must not produce the same id") + } +} + +func TestAuthorizePublisher(t *testing.T) { + t.Parallel() + + admin, second, stranger := addr(0x01), addr(0x02), addr(0xff) + + list := &pb.CohortSpec{ + Topic: topic(0xaa), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_LIST, + Admin: admin, + PublisherList: [][]byte{second}, + } + single := &pb.CohortSpec{ + Topic: topic(0xaa), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + Admin: admin, + } + + for _, tc := range []struct { + name string + spec *pb.CohortSpec + owner []byte + want error + }{ + {name: "list admin", spec: list, owner: admin}, + {name: "list member", spec: list, owner: second}, + {name: "list stranger", spec: list, owner: stranger, want: bps.ErrNotPublisher}, + {name: "single admin", spec: single, owner: admin}, + {name: "single non-admin", spec: single, owner: second, want: bps.ErrNotPublisher}, + {name: "empty owner", spec: list, owner: nil, want: bps.ErrNotPublisher}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := bps.AuthorizePublisher(tc.spec, tc.owner) + if tc.want == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("got %v want %v", err, tc.want) + } + }) + } +} diff --git a/pkg/bps/bps.go b/pkg/bps/bps.go new file mode 100644 index 00000000000..79752f934db --- /dev/null +++ b/pkg/bps/bps.go @@ -0,0 +1,252 @@ +// 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 bps + +import ( + "errors" + "sync" + "time" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/p2p" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// loggerName is the tree path name of the logger for this package. +const loggerName = "bps" + +// Wire identity. SWIP-60 names the libp2p stream "pubsub/1.0.0"; bee composes +// the protocol id as /swarm/{name}/{version}/{stream}. +const ( + ProtocolName = "pubsub" + ProtocolVersion = "1.0.0" + StreamName = "pubsub" +) + +const ( + // DefaultCapacity is the per-topic stream limit a broker enforces when + // none is configured. Capacity is broker policy, never a cohort parameter: + // a cohort cannot dictate a remote node's connection count. + DefaultCapacity = 32 + // HandshakeTimeout bounds how long a fresh stream may take to send Hello. + HandshakeTimeout = 30 * time.Second + // OutboundQueueSize bounds a peer stream's pending broadcasts. A peer that + // fills it is reset rather than allowed to stall the cohort. + OutboundQueueSize = 64 + // DedupCacheSize bounds a cohort's dedup horizon. SWIP-60 fixes the dedup + // rule but not its horizon; unbounded is a memory exhaustion vector. + DedupCacheSize = 1024 + // DefaultMaxCohorts is the number of cohorts a broker will fix when none + // is configured. Cohorts are never reclaimed, so this is what bounds the + // registry a remote peer can grow by opening topics. + DefaultMaxCohorts = 128 +) + +var ( + // ErrCohortFull is returned when a broker is at its per-topic capacity. + // A singlehop broker refuses and does nothing else: referral to another + // attachment point belongs to bps-multihop. + ErrCohortFull = errors.New("bps: cohort at capacity") + // ErrUnknownTopic is returned for a Subscribe naming a topic the broker + // does not serve. + ErrUnknownTopic = errors.New("bps: unknown topic") + // ErrSpecMismatch is returned for an Open naming an open topic with a + // different spec. + ErrSpecMismatch = errors.New("bps: cohort spec mismatch") + // ErrClosedCohort is returned when a non-publisher tries to join a closed + // cohort. + ErrClosedCohort = errors.New("bps: closed cohort admits publishers only") + // ErrShutdown is returned once the service is closing. + ErrShutdown = errors.New("bps: shutting down") +) + +// Options configures Service at construction. +type Options struct { + // Capacity is the per-topic stream limit this broker enforces. + // Zero means DefaultCapacity. + Capacity int + // MaxCohorts is the number of distinct cohorts this broker will fix. + // Zero means DefaultMaxCohorts. An Open that would exceed it is refused + // with FULL; joining a cohort that already exists is never affected. + MaxCohorts int +} + +// Service implements the BPS protocol. A node is a broker for the topics in +// its cohort registry and a client for the topics in its session map; the two +// are independent, and a node may be both for different topics. +type Service struct { + streamer p2p.Streamer + logger log.Logger + metrics metrics + capacity int + maxCohorts int + + cohortsMu sync.Mutex + // cohorts never lose entries once created: SWIP-60's cohort outlives its + // opener, and the last peer leaving does not destroy it. Reclamation of + // abandoned cohorts is future work, not an oversight. + cohorts map[string]*cohort + + sessionsMu sync.Mutex + sessions map[*Session]struct{} + + quit chan struct{} + quitOnce sync.Once +} + +// New returns a new BPS service. The streamer may be nil for a node that only +// brokers and never dials. +func New(streamer p2p.Streamer, logger log.Logger, o Options) *Service { + capacity := o.Capacity + if capacity <= 0 { + capacity = DefaultCapacity + } + maxCohorts := o.MaxCohorts + if maxCohorts <= 0 { + maxCohorts = DefaultMaxCohorts + } + return &Service{ + streamer: streamer, + logger: logger.WithName(loggerName).Register(), + metrics: newMetrics(), + capacity: capacity, + maxCohorts: maxCohorts, + cohorts: make(map[string]*cohort), + sessions: make(map[*Session]struct{}), + quit: make(chan struct{}), + } +} + +// Protocol returns the p2p protocol specification for registration with the +// p2p service. +func (s *Service) Protocol() p2p.ProtocolSpec { + return p2p.ProtocolSpec{ + Name: ProtocolName, + Version: ProtocolVersion, + StreamSpecs: []p2p.StreamSpec{ + { + Name: StreamName, + Handler: s.handler, + }, + }, + } +} + +// Topics returns the topics this node brokers. +func (s *Service) Topics() []swarm.Address { + s.cohortsMu.Lock() + defer s.cohortsMu.Unlock() + + out := make([]swarm.Address, 0, len(s.cohorts)) + for t := range s.cohorts { + out = append(out, swarm.NewAddress([]byte(t))) + } + return out +} + +// TopicStatus describes one topic this node participates in, for the API's +// GET /pubsub listing. +type TopicStatus struct { + Topic swarm.Address + Spec *pb.CohortSpec + Broker bool // true: this node brokers the topic; false: client session + Peers int // broker: retained streams; client: 1 (the broker link) +} + +// Status enumerates every topic this node participates in, brokered and +// client alike. A node that is both broker and client for the same topic +// yields two entries, one for each role. +func (s *Service) Status() []TopicStatus { + // Sized from the cohorts alone: len(s.sessions) is guarded by sessionsMu, + // and reading it here, under cohortsMu, races a concurrent Session.Close. + // The session entries are appended below and grow the slice as needed. + s.cohortsMu.Lock() + out := make([]TopicStatus, 0, len(s.cohorts)) + for t, c := range s.cohorts { + out = append(out, TopicStatus{ + Topic: swarm.NewAddress([]byte(t)), + Spec: c.spec, + Broker: true, + Peers: c.count(), + }) + } + s.cohortsMu.Unlock() + + s.sessionsMu.Lock() + for ss := range s.sessions { + out = append(out, TopicStatus{ + Topic: ss.Topic(), + Spec: ss.Spec(), + Broker: false, + Peers: 1, + }) + } + s.sessionsMu.Unlock() + + return out +} + +// Close stops the service and waits for its goroutines to terminate. +// +// Close does not itself track broker-side goroutines: each admitted stream's +// serve call owns its own reader and waits for it before returning (see +// broker.go), so nothing here needs to join that goroutine — it belongs to +// whichever p2p layer dispatched the stream's handler, not to Service. What +// Close does own is every live client Session, and Session.Close is +// synchronous with its own read loop, so closing every live session here +// transitively waits for all of them too. +func (s *Service) Close() error { + // quitOnce, not a bare select-on-quit/default, because Close is exported + // and callers do call it more than once: two concurrent calls could both + // take the default branch and both close(s.quit), which panics. Once + // makes the transition itself safe under concurrent Close calls; only + // the call that actually closes quit runs teardown, and every later + // call returns nil immediately, same as before. + first := false + s.quitOnce.Do(func() { + first = true + close(s.quit) + }) + if !first { + return nil + } + + s.sessionsMu.Lock() + live := make([]*Session, 0, len(s.sessions)) + for ss := range s.sessions { + live = append(live, ss) + } + s.sessionsMu.Unlock() + + stopped := make(chan struct{}) + go func() { + defer close(stopped) + for _, ss := range live { + _ = ss.Close() + } + }() + + select { + case <-stopped: + return nil + case <-time.After(5 * time.Second): + return errors.New("bps: waited 5 seconds to close active goroutines") + } +} + +// statusOf maps a handshake error to the wire status a broker answers with. +func statusOf(err error) pb.Status { + switch { + case err == nil: + return pb.Status_OK + case errors.Is(err, ErrCohortFull): + return pb.Status_FULL + case errors.Is(err, ErrUnknownTopic): + return pb.Status_UNKNOWN_TOPIC + default: + return pb.Status_REJECTED + } +} diff --git a/pkg/bps/bps_status_test.go b/pkg/bps/bps_status_test.go new file mode 100644 index 00000000000..ff5511cda99 --- /dev/null +++ b/pkg/bps/bps_status_test.go @@ -0,0 +1,94 @@ +// 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 bps_test + +import ( + "context" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// eventually polls check briefly, tolerating that broker-side peer +// registration may not yet be visible the instant a client's Open call +// returns, since the two run in different goroutines. +func eventually(t *testing.T, check func() bool) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for { + if check() { + return + } + if time.Now().After(deadline) { + t.Fatal("condition not met before deadline") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestStatus(t *testing.T) { + t.Parallel() + + broker, recorder, brokerAddr := newBroker(t, bps.Options{}) + spec := validSpec() + + client := bps.New(recorder, log.Noop, bps.Options{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatal(err) + } + }) + + ss, err := client.Open(context.Background(), brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + eventually(t, func() bool { + return len(broker.Status()) == 1 + }) + + brokerStatus := broker.Status() + if len(brokerStatus) != 1 { + t.Fatalf("broker status: got %d entries want 1", len(brokerStatus)) + } + bs := brokerStatus[0] + if !bs.Broker { + t.Fatal("broker status entry: got Broker=false want true") + } + if bs.Peers != 1 { + t.Fatalf("broker status entry: got Peers=%d want 1", bs.Peers) + } + if !bs.Topic.Equal(swarm.NewAddress(spec.Topic)) { + t.Fatalf("broker status entry: topic mismatch: got %x want %x", bs.Topic.Bytes(), spec.Topic) + } + if !bps.SpecEqual(bs.Spec, spec) { + t.Fatal("broker status entry: spec mismatch") + } + + clientStatus := client.Status() + if len(clientStatus) != 1 { + t.Fatalf("client status: got %d entries want 1", len(clientStatus)) + } + cs := clientStatus[0] + if cs.Broker { + t.Fatal("client status entry: got Broker=true want false") + } + if cs.Peers != 1 { + t.Fatalf("client status entry: got Peers=%d want 1", cs.Peers) + } + if !cs.Topic.Equal(swarm.NewAddress(spec.Topic)) { + t.Fatalf("client status entry: topic mismatch: got %x want %x", cs.Topic.Bytes(), spec.Topic) + } + if !bps.SpecEqual(cs.Spec, spec) { + t.Fatal("client status entry: spec mismatch") + } +} diff --git a/pkg/bps/bridge.go b/pkg/bps/bridge.go new file mode 100644 index 00000000000..35670f2ca33 --- /dev/null +++ b/pkg/bps/bridge.go @@ -0,0 +1,422 @@ +// 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 bps + +import ( + "context" + "errors" + "fmt" + "sync" + + ma "github.com/multiformats/go-multiaddr" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/bzz" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// ErrNoPeer is returned for an attach that would have to dial but names no +// broker underlay. Until broker discovery exists the caller must supply one. +var ErrNoPeer = errors.New("bps: no broker underlay address") + +// Connecter dials a broker's underlay address and reports its overlay. +type Connecter interface { + Connect(ctx context.Context, addrs []ma.Multiaddr) (*bzz.Address, error) +} + +// AttachOptions describes one local client joining a topic through the bridge. +type AttachOptions struct { + // Peer is the broker's underlay address. Required until broker discovery + // exists, but only consulted when the topic has no session yet. + Peer ma.Multiaddr + Topic swarm.Address + // Spec non-nil makes the first attach an Open; nil makes it a Subscribe. + Spec *pb.CohortSpec + // Owner non-nil (a 20-byte ethereum address) asks for a read-write + // attachment, and upgrades the topic's session if it is read-only. + Owner []byte +} + +// Attachment is one local sink on a muxed topic session. +type Attachment interface { + Spec() *pb.CohortSpec + // Messages is buffered OutboundQueueSize and closed on teardown, so a + // WebSocket client sees an EOF when the session ends. + Messages() <-chan *soc.SOC + Publish(ctx context.Context, s *soc.SOC) error + Close() error +} + +// Bridge multiplexes one p2p session per topic onto many local sinks. Its +// consumer is the WebSocket API, where several browser clients routinely watch +// the same topic and should cost the node one stream, not one each. +// +// Locking: a single mutex guards the whole entry table, and it is held across +// the dial that a first attach or a role upgrade performs. That is the coarse +// of the two options: a slow handshake stalls fan-out for every topic for up +// to HandshakeTimeout, and messages pile up in the sessions' own buffers +// meanwhile. It is chosen anyway because the alternative — a per-entry dialing +// state that other attaches wait on — has to answer what happens when the dial +// fails, when the waiter's context expires first, and when the entry is torn +// down and recreated under a waiter, and that is three chances to leak a +// session or close a channel twice. Correctness first; if dial-time head of +// line blocking ever shows up in practice, the entry table and the sink sets +// can be split onto separate locks without changing the exported surface. +type Bridge struct { + svc *Service + conn Connecter + logger log.Logger + + mu sync.Mutex + entries map[string]*entry + closed bool +} + +// entry is the bridge's state for one topic: the single upstream session and +// every local sink fed from it. All fields are guarded by Bridge.mu. +type entry struct { + topic swarm.Address + overlay swarm.Address // learned by the first dial, reused by upgrades + session *Session + sinks map[*attachment]struct{} + // done is closed when the fan-out goroutine for the current session + // returns. A role upgrade replaces both together. + done chan struct{} + // torndown marks the entry as removed from the table, so the fan-out + // goroutine knows the sinks were already closed by whoever removed it. + torndown bool +} + +// attachment is one local sink. Its mutable fields are guarded by Bridge.mu. +type attachment struct { + b *Bridge + e *entry + publisher bool + msgs chan *soc.SOC + // chClosed guards against closing msgs twice: either the last detach or + // the fan-out goroutine closes it, whichever reaches it first. + chClosed bool + detached bool +} + +var _ Attachment = (*attachment)(nil) + +// NewBridge returns a bridge over svc, dialing brokers through conn. +func NewBridge(svc *Service, conn Connecter, logger log.Logger) *Bridge { + return &Bridge{ + svc: svc, + conn: conn, + logger: logger.WithName(loggerName).Register(), + entries: make(map[string]*entry), + } +} + +// Attach joins a topic, opening the upstream session if this is the first +// local client on it and reusing it otherwise. A spec that disagrees with the +// live session's is refused rather than silently ignored: the spec is what +// every inbound message is verified against, so two clients on one session +// must agree on it. +func (b *Bridge) Attach(ctx context.Context, o AttachOptions) (Attachment, error) { + topic, err := attachTopic(o) + if err != nil { + return nil, err + } + key := topic.ByteString() + + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + return nil, ErrShutdown + } + + e, ok := b.entries[key] + if !ok { + e, err = b.dial(ctx, topic, o) + if err != nil { + return nil, err + } + b.entries[key] = e + b.start(e) + return b.sink(e, o), nil + } + + if o.Spec != nil && !SpecEqual(o.Spec, e.session.Spec()) { + return nil, fmt.Errorf("topic %s already attached: %w", topic, ErrSpecMismatch) + } + if len(o.Owner) > 0 && !e.session.publisher { + if err := b.upgrade(ctx, e, o); err != nil { + return nil, err + } + } + return b.sink(e, o), nil +} + +// Status delegates to the service: the bridge adds no topics of its own. +func (b *Bridge) Status() []TopicStatus { return b.svc.Status() } + +// Close detaches every sink, closes every session and waits for every fan-out +// goroutine the bridge started to return. +func (b *Bridge) Close() error { + b.mu.Lock() + b.closed = true + live := make([]*entry, 0, len(b.entries)) + for key, e := range b.entries { + delete(b.entries, key) + b.detachAll(e) + live = append(live, e) + } + b.mu.Unlock() + + for _, e := range live { + _ = e.session.Close() + <-e.done + } + return nil +} + +// dial resolves the broker's overlay and opens the topic's session. It is +// called with b.mu held; see the note on Bridge. +func (b *Bridge) dial(ctx context.Context, topic swarm.Address, o AttachOptions) (*entry, error) { + if o.Peer == nil { + return nil, ErrNoPeer + } + addr, err := b.conn.Connect(ctx, []ma.Multiaddr{o.Peer}) + if err != nil { + return nil, fmt.Errorf("connect broker: %w", err) + } + + ss, err := b.join(ctx, addr.Overlay, topic, o) + if err != nil { + return nil, err + } + return &entry{ + topic: topic, + overlay: addr.Overlay, + session: ss, + sinks: make(map[*attachment]struct{}), + }, nil +} + +// join performs the handshake an attach asks for: Open when it brings a spec, +// Subscribe when it does not. +func (b *Bridge) join(ctx context.Context, overlay, topic swarm.Address, o AttachOptions) (*Session, error) { + var auth *pb.PublisherAuth + if len(o.Owner) > 0 { + auth = &pb.PublisherAuth{Owner: o.Owner} + } + if o.Spec != nil { + return b.svc.Open(ctx, overlay, o.Spec, auth) + } + return b.svc.Subscribe(ctx, overlay, topic, auth) +} + +// upgrade replaces a read-only session with a read-write one, keeping the +// existing sinks. The role is fixed at handshake time, so a publisher joining +// a topic that was subscribed to read-only cannot be served by the live +// session — it needs one of its own. Called with b.mu held. +func (b *Bridge) upgrade(ctx context.Context, e *entry, o AttachOptions) error { + // The upgrade dials the overlay the first attach learned rather than + // asking the Connecter again: the node is already connected to the peer, + // and a second Connect on a live connection is at best redundant. + ns, err := b.join(ctx, e.overlay, e.topic, o) + if err != nil { + return fmt.Errorf("upgrade topic %s: %w", e.topic, err) + } + + old, done := e.session, e.done + e.session = ns + b.start(e) + + // The old session must be closed without b.mu: its fan-out goroutine takes + // the lock to look at the sinks, and Session.Close does not return until + // that goroutine's source of messages is gone. Unlocking here is safe + // because the entry already names the new session, so a concurrent attach + // sees the upgraded state, and the old goroutine sees that it has been + // superseded and returns without touching the sinks. A message the old + // goroutine was holding at that instant is dropped; the broker delivers to + // the new session from the moment it admitted it, so the loss window is + // bounded by the handshake, not by anything ongoing. + b.mu.Unlock() + _ = old.Close() + <-done + b.mu.Lock() + + // While the lock was released the last remaining sink may have detached + // and taken the whole entry down, closing the session this call had just + // swapped in. Attaching to it now would hand the caller a dead sink, so + // the attach is refused and the caller retries into a fresh entry. + if e.torndown { + return ErrShutdown + } + return nil +} + +// start launches the fan-out goroutine for the entry's current session. +// Called with b.mu held. +func (b *Bridge) start(e *entry) { + done := make(chan struct{}) + e.done = done + go b.fanout(e, e.session, done) +} + +// fanout delivers one session's messages to every sink on the entry. A sink +// whose buffer is full is dropped past rather than allowed to stall the +// session — one slow WebSocket client must not hold up the others, let alone +// the p2p stream feeding them all. +func (b *Bridge) fanout(e *entry, ss *Session, done chan struct{}) { + defer close(done) + + for m := range ss.Messages() { + b.mu.Lock() + if e.session != ss || e.torndown { + b.mu.Unlock() + return + } + for a := range e.sinks { + select { + case a.msgs <- m: + default: + b.svc.metrics.Dropped.WithLabelValues("slow_ws_client").Inc() + } + } + b.mu.Unlock() + } + + // The session ended on its own — the broker went away, or someone else + // closed it. Take the topic down so the sinks see an EOF rather than a + // channel that has simply gone quiet. + b.mu.Lock() + if e.session != ss || e.torndown { + b.mu.Unlock() + return + } + delete(b.entries, e.topic.ByteString()) + b.detachAll(e) + b.mu.Unlock() + + // Close the dead session too. Its read loop has already returned, but + // deregistration from the service's session set — and the stream reset — + // happen only in Session.Close, so skipping it would leave the session + // visible in Service.Status forever, and the bridge's own sinks are by now + // all detached, so no later Attachment.Close would ever reach it. Close is + // idempotent and does not block here, since readDone is already closed. + // It is called without b.mu: Session.Close takes only the service's + // sessionsMu, but there is no reason to hold the entry table for it. + _ = ss.Close() + b.logger.Debug("bridge: session ended", "topic", e.topic) +} + +// sink registers a new attachment on an entry. Called with b.mu held. +func (b *Bridge) sink(e *entry, o AttachOptions) *attachment { + a := &attachment{ + b: b, + e: e, + publisher: len(o.Owner) > 0, + msgs: make(chan *soc.SOC, OutboundQueueSize), + } + e.sinks[a] = struct{}{} + return a +} + +// detachAll closes every sink on an entry and marks it torn down. It does not +// close the session — the caller does that after releasing b.mu. Called with +// b.mu held. +func (b *Bridge) detachAll(e *entry) { + e.torndown = true + for a := range e.sinks { + delete(e.sinks, a) + a.detached = true + a.closeCh() + } +} + +// closeCh closes the sink's channel at most once. Both the last detach and the +// fan-out goroutine can reach a sink, so the guard is not theoretical. Called +// with b.mu held. +func (a *attachment) closeCh() { + if !a.chClosed { + a.chClosed = true + close(a.msgs) + } +} + +// Spec returns the cohort spec of the session behind this attachment. +func (a *attachment) Spec() *pb.CohortSpec { + a.b.mu.Lock() + defer a.b.mu.Unlock() + return a.e.session.Spec() +} + +func (a *attachment) Messages() <-chan *soc.SOC { return a.msgs } + +// Publish sends through the shared session. The role is per attachment, not +// per session: another client upgrading the topic to read-write does not make +// this sink a publisher. +func (a *attachment) Publish(ctx context.Context, s *soc.SOC) error { + if !a.publisher { + return fmt.Errorf("read-only attachment: %w", ErrNotPublisher) + } + + a.b.mu.Lock() + if a.detached { + a.b.mu.Unlock() + return ErrShutdown + } + ss := a.e.session + a.b.mu.Unlock() + + return ss.Publish(ctx, s) +} + +// Close detaches this sink, and tears the topic's session down if it was the +// last one on it. +func (a *attachment) Close() error { + b := a.b + + b.mu.Lock() + if a.detached { + b.mu.Unlock() + return nil + } + a.detached = true + delete(a.e.sinks, a) + a.closeCh() + + e := a.e + last := !e.torndown && len(e.sinks) == 0 + if last { + e.torndown = true + delete(b.entries, e.topic.ByteString()) + } + b.mu.Unlock() + + if last { + _ = e.session.Close() + <-e.done + } + return nil +} + +// attachTopic resolves the topic an attach names, from the option or from the +// spec it brings, and refuses the two disagreeing. +func attachTopic(o AttachOptions) (swarm.Address, error) { + if o.Spec == nil { + if o.Topic.IsZero() { + return swarm.ZeroAddress, fmt.Errorf("no topic and no spec: %w", ErrInvalidSpec) + } + return o.Topic, nil + } + if err := ValidateSpec(o.Spec); err != nil { + return swarm.ZeroAddress, err + } + t := swarm.NewAddress(o.Spec.GetTopic()) + if !o.Topic.IsZero() && !o.Topic.Equal(t) { + return swarm.ZeroAddress, fmt.Errorf("topic %s is not the spec's: %w", o.Topic, ErrSpecMismatch) + } + return t, nil +} diff --git a/pkg/bps/bridge_test.go b/pkg/bps/bridge_test.go new file mode 100644 index 00000000000..e526733e4e3 --- /dev/null +++ b/pkg/bps/bridge_test.go @@ -0,0 +1,400 @@ +// 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 bps_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + ma "github.com/multiformats/go-multiaddr" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/bzz" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// countingConnecter is a Connecter that always resolves to the same overlay and +// counts how many times it was asked to dial, which is how the mux tests prove +// that a second attach on a live topic reuses the session rather than opening +// a second one. +type countingConnecter struct { + mu sync.Mutex + n int + addr *bzz.Address +} + +func (c *countingConnecter) Connect(_ context.Context, _ []ma.Multiaddr) (*bzz.Address, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.n++ + return c.addr, nil +} + +func (c *countingConnecter) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.n +} + +// newBridge returns a broker, a bridge whose client service routes to it, the +// bridge's counting connecter and the underlay every attach is made with. +func newBridge(t *testing.T) (*bps.Service, *bps.Bridge, *countingConnecter, ma.Multiaddr) { + t.Helper() + + broker, recorder, brokerAddr := newBroker(t, bps.Options{}) + + client := bps.New(recorder, log.Noop, bps.Options{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatal(err) + } + }) + + underlay, err := ma.NewMultiaddr("/ip4/127.0.0.1/tcp/1634") + if err != nil { + t.Fatal(err) + } + conn := &countingConnecter{addr: &bzz.Address{ + Underlays: []ma.Multiaddr{underlay}, + Overlay: brokerAddr, + }} + + bridge := bps.NewBridge(client, conn, log.Noop) + t.Cleanup(func() { + if err := bridge.Close(); err != nil { + t.Fatal(err) + } + }) + return broker, bridge, conn, underlay +} + +// recvSink reads one message from an attachment's sink. +func recvSink(t *testing.T, a bps.Attachment) *soc.SOC { + t.Helper() + + select { + case s, ok := <-a.Messages(): + if !ok { + t.Fatal("attachment closed before delivering a message") + } + return s + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a broadcast") + return nil + } +} + +func TestBridgeMuxTwoSinks(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, bridge, conn, underlay := newBridge(t) + spec, _, msg := anchorCohort(t, topic(0x71), []byte("muxed")) + + pub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + Owner: spec.Admin, + }) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + }) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if got := conn.count(); got != 1 { + t.Fatalf("dials: got %d want 1", got) + } + if !bps.SpecEqual(sub.Spec(), spec) { + t.Fatal("second attachment: spec mismatch") + } + + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + + for i, a := range []bps.Attachment{pub, sub} { + got := recvSink(t, a) + if !got.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatalf("sink %d received the wrong message", i) + } + } +} + +func TestBridgeRoleUpgrade(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, bridge, conn, underlay := newBridge(t) + spec, _, msg := anchorCohort(t, topic(0x72), []byte("upgraded")) + + // A read-only attach first: it fixes the cohort but may not publish. + sub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + }) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if err := sub.Publish(ctx, msg); err == nil { + t.Fatal("read-only attachment published") + } + + pub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + Owner: spec.Admin, + }) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + // The upgrade reuses the overlay learned by the first dial. + if got := conn.count(); got != 1 { + t.Fatalf("dials: got %d want 1", got) + } + + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + + for i, a := range []bps.Attachment{pub, sub} { + got := recvSink(t, a) + if !got.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatalf("sink %d received the wrong message", i) + } + } +} + +func TestBridgeSpecMismatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, bridge, _, underlay := newBridge(t) + spec, _, _ := anchorCohort(t, topic(0x74), []byte("mismatched")) + + a, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + Owner: spec.Admin, + }) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + // Same topic, different cohort: the live session verifies every inbound + // message against its own spec, so a second client cannot be served on it + // under different rules. + other := &pb.CohortSpec{ + Topic: spec.Topic, + Binding: spec.Binding, + Publishers: spec.Publishers, + Admin: spec.Admin, + Closed: true, + } + if _, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: other, + }); !errors.Is(err, bps.ErrSpecMismatch) { + t.Fatalf("attach with a conflicting spec: got %v want %v", err, bps.ErrSpecMismatch) + } +} + +func TestBridgeSlowSinkIsDroppedPast(t *testing.T) { + t.Parallel() + + ctx := context.Background() + _, bridge, _, underlay := newBridge(t) + spec, signer, _ := anchorCohort(t, topic(0x75), []byte("slow")) + + att := func(owner []byte) bps.Attachment { + t.Helper() + a, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + Owner: owner, + }) + if err != nil { + t.Fatal(err) + } + return a + } + + pub := att(spec.Admin) + defer pub.Close() + fast := att(nil) + slow := att(nil) // never drained + defer slow.Close() + + // More messages than a sink's buffer holds, so the undrained one must + // overflow while the drained one keeps up. + const count = bps.OutboundQueueSize + 6 + + // Drain the fast sink concurrently: the broker resets a peer whose own + // outbound queue overflows, so the pipeline has to keep moving while the + // publisher writes. + got := make(chan int) + go func() { + n := 0 + for range fast.Messages() { + n++ + if n == count { + break + } + } + got <- n + }() + + for i := 0; i < count; i++ { + msg := bpstesting.AnchorSOC(t, signer, topic(0x75), []byte{byte(i)}) + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + } + + select { + case n := <-got: + if n != count { + t.Fatalf("fast sink: got %d messages want %d", n, count) + } + case <-time.After(10 * time.Second): + t.Fatal("fast sink did not keep up while another sink stalled") + } + if err := fast.Close(); err != nil { + t.Fatal(err) + } + + // The slow sink kept exactly a bufferful; the rest were dropped past it + // rather than stalling the session. Closing it leaves the buffered + // messages readable, so they can be counted. + if err := slow.Close(); err != nil { + t.Fatal(err) + } + n := 0 + for range slow.Messages() { + n++ + } + if n != bps.OutboundQueueSize { + t.Fatalf("slow sink: got %d buffered messages want %d", n, bps.OutboundQueueSize) + } +} + +func TestBridgeSessionEndTearsDown(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, bridge, _, underlay := newBridge(t) + spec, _, _ := anchorCohort(t, topic(0x76), []byte("broker gone")) + + sub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + }) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + // The broker goes away under the session: it resets every retained stream, + // which ends the client session without anyone locally asking for it. + if err := broker.Close(); err != nil { + t.Fatal(err) + } + + select { + case _, ok := <-sub.Messages(): + if ok { + t.Fatal("sink delivered a message after the broker went away") + } + case <-time.After(5 * time.Second): + t.Fatal("sink channel not closed after the session ended") + } + + // The dead session must not linger in the status listing the API serves. + eventually(t, func() bool { + return len(bridge.Status()) == 0 + }) +} + +func TestBridgeLastCloseTearsDown(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, bridge, _, underlay := newBridge(t) + spec, _, _ := anchorCohort(t, topic(0x73), []byte("torn down")) + + pub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + Spec: spec, + Owner: spec.Admin, + }) + if err != nil { + t.Fatal(err) + } + sub, err := bridge.Attach(ctx, bps.AttachOptions{ + Peer: underlay, + Topic: swarm.NewAddress(spec.Topic), + }) + if err != nil { + t.Fatal(err) + } + + // One p2p stream carries both sinks. + eventually(t, func() bool { + st := broker.Status() + return len(st) == 1 && st[0].Peers == 1 + }) + + // The first detach leaves the session up for the remaining sink... + if err := pub.Close(); err != nil { + t.Fatal(err) + } + if st := broker.Status(); len(st) != 1 || st[0].Peers != 1 { + t.Fatal("session torn down while a sink was still attached") + } + + // ...and the last one takes it down. + if err := sub.Close(); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { + st := broker.Status() + return len(st) == 1 && st[0].Peers == 0 + }) + + if _, ok := <-sub.Messages(); ok { + t.Fatal("sink channel still open after teardown") + } + if len(bridge.Status()) != 0 { + t.Fatalf("bridge status: got %d entries want 0", len(bridge.Status())) + } +} diff --git a/pkg/bps/broadcast_test.go b/pkg/bps/broadcast_test.go new file mode 100644 index 00000000000..a8df9400355 --- /dev/null +++ b/pkg/bps/broadcast_test.go @@ -0,0 +1,487 @@ +// 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 bps_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// anchorCohort builds a single-publisher ANCHOR cohort around one signer, and +// a helper that mints qualifying messages for it. +func anchorCohort(t *testing.T, id []byte, payload []byte) (*pb.CohortSpec, crypto.Signer, *soc.SOC) { + t.Helper() + + signer, owner := bpstesting.NewSigner(t) + s := bpstesting.AnchorSOC(t, signer, id, payload) + anchor, err := s.Address() + if err != nil { + t.Fatal(err) + } + return &pb.CohortSpec{ + Topic: anchor.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + Admin: owner, + }, signer, s +} + +func recv(t *testing.T, ss *bps.Session) *soc.SOC { + t.Helper() + + select { + case s, ok := <-ss.Messages(): + if !ok { + t.Fatal("session closed before delivering a message") + } + return s + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a broadcast") + return nil + } +} + +func TestBroadcastReachesEveryPeer(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, msg := anchorCohort(t, topic(0x41), []byte("first message")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + + // The subscriber receives it... + got := recv(t, sub) + if !got.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatal("subscriber received the wrong message") + } + // ...and so does the publisher, on its own stream. + own := recv(t, pub) + if !own.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatal("publisher did not receive its own message") + } +} + +func TestBroadcastDeduplicates(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, signer, first := anchorCohort(t, topic(0x51), []byte("first")) + // A second message under the same id and owner — same anchor, different + // payload, so it qualifies and is not a duplicate. + second := bpstesting.AnchorSOC(t, signer, topic(0x51), []byte("second")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if err := pub.Publish(ctx, first); err != nil { + t.Fatal(err) + } + if err := pub.Publish(ctx, first); err != nil { + t.Fatal(err) + } + if err := pub.Publish(ctx, second); err != nil { + t.Fatal(err) + } + + // The duplicate is dropped by the broker, so the subscriber sees + // first then second, never first twice. + if got := recv(t, sub); !got.WrappedChunk().Address().Equal(first.WrappedChunk().Address()) { + t.Fatal("expected the first message") + } + if got := recv(t, sub); !got.WrappedChunk().Address().Equal(second.WrappedChunk().Address()) { + t.Fatal("expected the second message; the duplicate was not dropped") + } +} + +func TestBroadcastDropsUnauthorizedMessage(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, msg := anchorCohort(t, topic(0x61), []byte("legitimate")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + // Under the explicit publisher regime the anchor binding no longer checks + // the SOC address against the topic (SWIP-60: the id does no protocol + // work there), so a message signed by an owner outside the cohort is now + // refused by authorization instead: it qualifies but is not the admin. + impostorSigner, _ := bpstesting.NewSigner(t) + impostor := bpstesting.AnchorSOC(t, impostorSigner, spec.Topic, []byte("impostor")) + if err := pub.Publish(ctx, impostor); err == nil { + t.Fatal("expected the session to refuse a message from a non-listed owner") + } + + // A legitimate message still gets through afterwards. + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + if got := recv(t, sub); !got.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatal("expected the legitimate message") + } +} + +// TestPublisherAuthIsNotACredential pins that the owner a peer declares in its +// handshake buys it nothing. The broker admits it — the declared owner is +// unauthenticated, and admission is deliberately only an early refusal — but +// the authenticating check runs at publish time against the owner recovered +// from the message signature, so a peer that named someone else's address +// cannot get a message out to the cohort. +func TestPublisherAuthIsNotACredential(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, streamer, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, msg := anchorCohort(t, topic(0x91), []byte("legitimate")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + // The impostor claims the admin's address, which it has no key for, and + // is admitted as a publisher on that claim alone. + _, w, _, ack := rawStream(t, streamer, brokerAddr, subscribeHello(spec.Topic, &pb.PublisherAuth{Owner: spec.Admin})) + if ack.Status != pb.Status_OK { + t.Fatalf("impostor admission: got %s want OK", ack.Status) + } + + // It then publishes a message signed by its own key. + impostorSigner, _ := bpstesting.NewSigner(t) + impostorMsg := bpstesting.AnchorSOC(t, impostorSigner, topic(0x91), []byte("impostor")) + m, err := bps.SocToProto(impostorMsg) + if err != nil { + t.Fatal(err) + } + if err := w.WriteMsgWithContext(ctx, &pb.Publish{Soc: m}); err != nil { + t.Fatal(err) + } + + // Give the broker a moment to have processed the impostor's frame before + // the legitimate one is sent, so that "the subscriber's first message is + // the legitimate one" really means the impostor's was dropped rather than + // merely overtaken. + time.Sleep(100 * time.Millisecond) + + if err := pub.Publish(ctx, msg); err != nil { + t.Fatal(err) + } + + got := recv(t, sub) + if !got.WrappedChunk().Address().Equal(msg.WrappedChunk().Address()) { + t.Fatal("the impostor's message reached a subscriber") + } +} + +// TestBroadcastDropsSlowPeer pins the design's slow-peer promise: a peer that +// stops draining fills its bounded outbound queue, is dropped from the cohort +// and has its stream reset, and the cohort goes on serving everyone else. +// Without it, one stalled reader would back the broker's fan-out up behind it. +// +// The drop is observed through the cohort's capacity — a slot the dropped peer +// held becomes free — rather than by watching its stream end. streamtest's +// in-memory pipe holds its record lock across a blocked write, so once the +// broker's write to a peer that never reads has jammed, nothing can close or +// drain that pipe from either side. That is a harness artifact, not the +// behaviour under test: a real libp2p Reset does not wait on the writer. +func TestBroadcastDropsSlowPeer(t *testing.T) { + t.Parallel() + + // Enough to overrun the slow peer's outbound queue: the broker's writes to + // it park once its stream stops being drained, and everything after that + // piles up in the queue until it overflows. + const storm = 3 * bps.OutboundQueueSize + + ctx := context.Background() + // Exactly three slots: the publisher, the healthy subscriber and the slow + // peer. A fourth handshake is refused while the slow peer holds its slot, + // and admitted once it has been dropped. + broker, streamer, brokerAddr := newBroker(t, bps.Options{Capacity: 3}) + client := newClient(t, broker, brokerAddr) + + spec, signer, _ := anchorCohort(t, topic(0xa0), []byte("slow peer")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + healthy, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer healthy.Close() + + // The slow peer completes the handshake and then never reads again. Its + // stream is deliberately not reset on cleanup: see the note above on the + // jammed pipe. + slowStream, err := streamer.NewStream(ctx, brokerAddr, nil, bps.ProtocolName, bps.ProtocolVersion, bps.StreamName) + if err != nil { + t.Fatal(err) + } + slowWriter, slowReader := protobuf.NewWriterAndReader(slowStream) + if err := slowWriter.WriteMsgWithContext(ctx, subscribeHello(spec.Topic, nil)); err != nil { + t.Fatal(err) + } + var slowAck pb.Ack + if err := slowReader.ReadMsgWithContext(ctx, &slowAck); err != nil { + t.Fatal(err) + } + if slowAck.Status != pb.Status_OK { + t.Fatalf("slow peer admission: got %s want OK", slowAck.Status) + } + + // With every slot taken, a further peer is refused. + if ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)); ack.Status != pb.Status_FULL { + t.Fatalf("cohort should be full: got %s want FULL", ack.Status) + } + + // The publisher's own copy of every broadcast is drained continuously so + // that it, unlike the slow peer, never fills its own queue. + go func() { + for range pub.Messages() { + } + }() + + // Publishing is paced against the healthy subscriber, one message at a + // time. streamtest's in-memory pipe can only carry a bounded number of + // unconsumed writes, so a tight publish loop would stall the test harness + // itself long before it stalled the broker. The slow peer is the only + // party here that is deliberately not kept up with. + publish := func(payload string) *soc.SOC { + t.Helper() + + m := bpstesting.AnchorSOC(t, signer, topic(0xa0), []byte(payload)) + if err := pub.Publish(ctx, m); err != nil { + t.Fatal(err) + } + return m + } + + for i := range storm { + publish(fmt.Sprintf("storm %d", i)) + if got := recv(t, healthy); got == nil { + t.Fatal("healthy subscriber stopped receiving mid-storm") + } + } + + // The slow peer's slot is free again: it was dropped from the cohort, not + // merely left behind on its own stream. + if ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)); ack.Status != pb.Status_OK { + t.Fatalf("slow peer was not dropped: got %s want OK", ack.Status) + } + + // The cohort keeps serving: a message published after the drop still + // reaches the healthy subscriber. + sentinel := publish("after the drop") + if got := recv(t, healthy); !got.WrappedChunk().Address().Equal(sentinel.WrappedChunk().Address()) { + t.Fatal("the cohort stopped serving after dropping the slow peer") + } +} + +// TestSessionConcurrentPublish pins that Publish is safe for concurrent use, +// as its exported contract promises. The session's writer wraps mutable +// framing state; unserialised, two goroutines interleave bytes on the wire and +// desynchronise the broker's framing for good. Run under -race this catches +// the data race directly; without it, the corrupted framing shows up as +// messages that never arrive. +func TestSessionConcurrentPublish(t *testing.T) { + t.Parallel() + + // Rounds of concurrent writers, rather than one long tight loop: the + // writers within a round really do race each other for the session's + // writer, while draining between rounds keeps streamtest's in-memory pipe + // from stalling on unconsumed writes. + const ( + writers = 8 + rounds = 5 + ) + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, signer, _ := anchorCohort(t, topic(0xb0), []byte("concurrent")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + // The publisher's own copy of every broadcast is drained continuously. + go func() { + for range pub.Messages() { + } + }() + + seen := make(map[string]struct{}, writers*rounds) + for round := range rounds { + start := make(chan struct{}) + var wg sync.WaitGroup + for w := range writers { + wg.Add(1) + go func(w int) { + defer wg.Done() + + m := bpstesting.AnchorSOC(t, signer, topic(0xb0), []byte(fmt.Sprintf("round %d writer %d", round, w))) + <-start + if err := pub.Publish(ctx, m); err != nil { + t.Errorf("publish: %v", err) + } + }(w) + } + close(start) + wg.Wait() + + // Every distinct message arrives exactly once: nothing was lost to a + // mangled frame, and nothing was duplicated. + for range writers { + m := recv(t, sub) + addr := m.WrappedChunk().Address().String() + if _, ok := seen[addr]; ok { + t.Fatalf("message %s delivered twice", addr) + } + seen[addr] = struct{}{} + } + } + if len(seen) != writers*rounds { + t.Fatalf("delivered %d distinct messages, want %d", len(seen), writers*rounds) + } +} + +// TestCloseWithLivePublisherStream ensures Close does not exceed its 5-second +// budget when a publisher stream is still attached: the broker's serve call +// for that stream has a reader goroutine potentially blocked in ReadMsg on +// the very same stream Close's teardown must also touch. If Close (or serve) +// ever went back to resetting/closing that stream in a way that waits on the +// stream itself while the reader is also blocked on it, this would hang for +// the full 5 seconds instead of returning promptly. +func TestCloseWithLivePublisherStream(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, _ := anchorCohort(t, topic(0x81), []byte("still attached")) + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + // Deliberately not closed before broker.Close() below: the broker's + // serve and readPublished goroutines for this stream are still live. + + done := make(chan error, 1) + go func() { + done <- broker.Close() + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("got %v want nil", err) + } + case <-time.After(4 * time.Second): + t.Fatal("Close did not return within 4 seconds of its 5-second budget") + } +} + +func TestCloseTearsDownSessions(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, _ := anchorCohort(t, topic(0x71), []byte("teardown")) + + sess, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + if err := sess.Close(); err != nil { + t.Fatal(err) + } + + select { + case _, ok := <-sess.Messages(): + if ok { + t.Fatal("expected the message channel to be closed") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the message channel to close") + } +} diff --git a/pkg/bps/broker.go b/pkg/bps/broker.go new file mode 100644 index 00000000000..8f5c4638837 --- /dev/null +++ b/pkg/bps/broker.go @@ -0,0 +1,458 @@ +// 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 bps + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/p2p" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// cohort is one topic's brokered state. All fields are guarded by +// Service.cohortsMu except peers and dedup, which have their own mutex so +// fan-out does not block the handshake path. +type cohort struct { + spec *pb.CohortSpec + binding binding + + mu sync.Mutex + peers map[*peerStream]struct{} + dedup map[string]struct{} + order [][]byte // insertion order, for evicting the oldest dedup entry +} + +func newCohort(spec *pb.CohortSpec, b binding) *cohort { + return &cohort{ + spec: spec, + binding: b, + peers: make(map[*peerStream]struct{}), + dedup: make(map[string]struct{}), + } +} + +// peerStream is one retained stream in a cohort, with a bounded outbound queue +// drained by a single writer goroutine. +type peerStream struct { + peer swarm.Address + publisher bool + out chan *pb.Soc + quit chan struct{} + closeOnce sync.Once +} + +func newPeerStream(peer swarm.Address, publisher bool) *peerStream { + return &peerStream{ + peer: peer, + publisher: publisher, + out: make(chan *pb.Soc, OutboundQueueSize), + quit: make(chan struct{}), + } +} + +func (ps *peerStream) close() { + ps.closeOnce.Do(func() { close(ps.quit) }) +} + +func (c *cohort) add(ps *peerStream) { + c.mu.Lock() + defer c.mu.Unlock() + c.peers[ps] = struct{}{} +} + +func (c *cohort) remove(ps *peerStream) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.peers, ps) +} + +func (c *cohort) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.peers) +} + +// seen reports whether key was already broadcast in this cohort, recording it +// if not. The horizon is bounded: SWIP-60 fixes the dedup rule but not its +// size, and an unbounded set is a memory exhaustion vector. A bounded horizon +// is a memory bound, not a replay defence — replay defence arrives with +// history delivery. +func (c *cohort) seen(key []byte) bool { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.dedup[string(key)]; ok { + return true + } + if len(c.order) >= DedupCacheSize { + delete(c.dedup, string(c.order[0])) + c.order = c.order[1:] + } + c.dedup[string(key)] = struct{}{} + c.order = append(c.order, key) + + return false +} + +// fanout enqueues m on every peer stream and returns those whose queue was +// full. A peer that cannot keep up is reset rather than allowed to stall the +// cohort: SWIP-60 gives the broker no delivery obligation, and withholding is +// a liveness fault the peer recovers from by reconnecting. +func (c *cohort) fanout(m *pb.Soc) []*peerStream { + c.mu.Lock() + defer c.mu.Unlock() + + var dropped []*peerStream + for ps := range c.peers { + select { + case ps.out <- m: + default: + dropped = append(dropped, ps) + } + } + return dropped +} + +// handler serves an inbound BPS stream. The first frame is a Hello wrapping +// Open or Subscribe; the broker answers with Ack. A successful handshake +// hands the stream to serve, which retains it for the cohort's lifetime and +// becomes its sole owner for teardown from that point on — handler must not +// touch the stream again once serve has been called. +func (s *Service) handler(ctx context.Context, p p2p.Peer, stream p2p.Stream) error { + select { + case <-s.quit: + _ = stream.Reset() + return ErrShutdown + default: + } + + w, r := protobuf.NewWriterAndReader(stream) + + helloCtx, cancel := context.WithTimeout(ctx, HandshakeTimeout) + var hello pb.Hello + err := r.ReadMsgWithContext(helloCtx, &hello) + cancel() + if err != nil { + _ = stream.Reset() + return fmt.Errorf("read hello: %w", err) + } + + c, ps, joinErr := s.join(&hello, p.Address) + status := statusOf(joinErr) + s.metrics.Handshakes.WithLabelValues(status.String()).Inc() + if joinErr != nil { + s.logger.Debug("handshake refused", "peer_address", p.Address, "status", status, "error", joinErr) + } + + ack := &pb.Ack{Status: status} + if joinErr == nil { + ack.Cohort = c.spec + } + // The Ack write is bounded by the same budget as the Hello read: a peer + // that opens a stream, says Hello and then never reads would otherwise + // hold this handler goroutine until it disconnects. + ackCtx, ackCancel := context.WithTimeout(ctx, HandshakeTimeout) + err = w.WriteMsgWithContext(ackCtx, ack) + ackCancel() + if err != nil { + if joinErr == nil { + // Admitted but never got to serve: undo the registration admit + // made under cohortsMu, since serve will never run to do it. + c.remove(ps) + } + _ = stream.Reset() + return fmt.Errorf("write ack: %w", err) + } + if joinErr != nil { + _ = stream.FullClose() + return nil + } + + return s.serve(ctx, p, c, ps, stream, w, r) +} + +// serve retains an admitted stream for the cohort's lifetime: this goroutine +// drains ps's outbound queue and writes broadcasts, while a second goroutine +// — for publishers — reads Publish frames. serve is the sole owner of the +// stream's teardown once handler hands it off: whichever condition ends the +// session, serve resets the stream itself and waits for its own reader +// goroutine to actually return before returning, so no goroutine started +// here outlives serve, and nothing else — in particular not handler's own +// caller — ever closes this stream concurrently with serve. +func (s *Service) serve(ctx context.Context, p p2p.Peer, c *cohort, ps *peerStream, stream p2p.Stream, w protobuf.Writer, r protobuf.Reader) error { + defer func() { + c.remove(ps) + ps.close() + }() + + // The write below returns only on completion or on cancellation of the + // context it is given, and ctx is the libp2p per-stream context, cancelled + // only when the peer disconnects or p2p shuts down. A peer that holds the + // connection open but stops draining its flow-control window would + // therefore park this goroutine inside the write, past ps.quit, past + // s.quit and past the slow-peer reset, leaving Service.Close to return nil + // while this stream and its goroutines leak. writeCtx bridges both quit + // channels into cancellation so the write is actually interruptible. + // + // The bridging goroutine always terminates: every path out of serve runs + // the deferred cancelWrite, which completes writeCtx.Done() and so ends + // the select even when neither quit channel ever closes. + writeCtx, cancelWrite := context.WithCancel(ctx) + bridgeDone := make(chan struct{}) + go func() { + defer close(bridgeDone) + select { + case <-ps.quit: + cancelWrite() + case <-s.quit: + cancelWrite() + case <-writeCtx.Done(): + } + }() + defer func() { + cancelWrite() + <-bridgeDone + }() + + var readerDone chan struct{} + if ps.publisher { + readerDone = make(chan struct{}) + go func() { + defer close(readerDone) + defer ps.close() + s.readPublished(p, c, r) + }() + } + + var loopErr error +loop: + for { + select { + case m := <-ps.out: + if err := w.WriteMsgWithContext(writeCtx, &pb.Broadcast{Frame: &pb.Broadcast_Soc{Soc: m}}); err != nil { + // A write cut short by our own shutdown or by this peer's + // reset is not a failure to report; only a genuine write + // error is. + select { + case <-ps.quit: + case <-s.quit: + default: + loopErr = fmt.Errorf("write broadcast: %w", err) + } + break loop + } + case <-ps.quit: + break loop + case <-s.quit: + break loop + case <-ctx.Done(): + loopErr = ctx.Err() + break loop + } + } + + // Reset first, to unblock a reader goroutine that may be mid-ReadMsg on + // this same stream, then wait for it to actually stop. A real libp2p + // stream's FullClose reads from the stream to observe the peer's own + // close (pkg/p2p/libp2p/stream.go), so calling it here while the reader + // might still be blocked in a read would be two concurrent readers of + // one connection; Reset carries no such obligation, which is why it — + // never FullClose — is what ends a retained stream. + _ = stream.Reset() + if readerDone != nil { + <-readerDone + } + return loopErr +} + +// readPublished consumes a publisher's Publish frames, validates each against +// the cohort's binding and publisher regime, deduplicates, and fans out. +func (s *Service) readPublished(p p2p.Peer, c *cohort, r protobuf.Reader) { + for { + var msg pb.Publish + if err := r.ReadMsg(&msg); err != nil { + s.logger.Debug("read publish", "peer_address", p.Address, "error", err) + return + } + s.publish(p, c, msg.GetSoc()) + } +} + +func (s *Service) publish(p p2p.Peer, c *cohort, m *pb.Soc) { + reason, err := s.validate(c, m) + if err != nil { + s.metrics.Dropped.WithLabelValues(reason).Inc() + s.metrics.Invalid.Inc() + // Per-peer attribution stays in the debug log, deliberately. Labelling + // a metric by peer address lets any remote peer mint unbounded + // Prometheus time series, and no other bee metric does it. The + // blocklisting policy this was meant to feed wants per-peer state in + // memory, not in the metrics registry. + s.logger.Debug("dropping message", "peer_address", p.Address, "reason", reason, "error", err) + return + } + + for _, ps := range c.fanout(m) { + s.metrics.Dropped.WithLabelValues("slow_peer").Inc() + s.logger.Debug("resetting slow peer", "peer_address", ps.peer) + // Unregister immediately: otherwise this peer stays in c.peers, + // visible to and re-dropped by, every fanout until its own serve + // call unwinds and removes it. + c.remove(ps) + ps.close() + } + s.metrics.Broadcast.Inc() +} + +// validate runs the broker checks SWIP-60 requires on Publish: the SOC is +// well-formed and its owner matches its signature, it qualifies under the +// topic binding, its owner is a legitimate publisher, and it is not a +// duplicate. It returns a metric label alongside the error. +func (s *Service) validate(c *cohort, m *pb.Soc) (string, error) { + if m == nil { + return "malformed", ErrMalformedSoc + } + sc, err := SocFromProto(m) + if err != nil { + return "malformed", err + } + if err := c.binding.qualifies(c.spec, sc); err != nil { + return "unqualified", err + } + if err := authorizePublisher(c.spec, sc.OwnerAddress()); err != nil { + return "not_publisher", err + } + key, err := c.binding.dedupKey(sc) + if err != nil { + return "malformed", err + } + if c.seen(key) { + return "duplicate", errors.New("bps: duplicate message") + } + return "", nil +} + +// join resolves the handshake against the cohort registry, creating the +// cohort when the frame is an Open for an unserved topic. On success it +// returns the peer's newly registered peerStream. +func (s *Service) join(hello *pb.Hello, peer swarm.Address) (*cohort, *peerStream, error) { + switch { + case hello.GetOpen() != nil: + return s.open(hello.GetOpen(), peer) + case hello.GetSubscribe() != nil: + return s.subscribe(hello.GetSubscribe(), peer) + default: + return nil, nil, fmt.Errorf("empty hello: %w", ErrInvalidSpec) + } +} + +func (s *Service) open(open *pb.Open, peer swarm.Address) (*cohort, *peerStream, error) { + spec := open.GetCohort() + if err := ValidateSpec(spec); err != nil { + return nil, nil, err + } + b, err := bindingFor(spec.GetBinding()) + if err != nil { + return nil, nil, err + } + + s.cohortsMu.Lock() + defer s.cohortsMu.Unlock() + + key := string(spec.GetTopic()) + if existing, ok := s.cohorts[key]; ok { + // SWIP-60: an Open naming an already-open topic with an identical spec + // is equivalent to Subscribe; a mismatched spec is refused. + if !SpecEqual(existing.spec, spec) { + return nil, nil, ErrSpecMismatch + } + ps, err := s.admit(existing, peer, open.GetAuth()) + if err != nil { + return nil, nil, err + } + return existing, ps, nil + } + + // Only the creation of a *new* cohort is capped; joining an existing one + // is unaffected. Capacity limits streams per topic and says nothing about + // how many topics one peer may fix, so without this a single peer can + // Open unlimited distinct valid specs, each retaining a spec and a dedup + // horizon that by design nothing ever reclaims. This is a cap, not + // reclamation: cohorts still outlive their opener. + if len(s.cohorts) >= s.maxCohorts { + return nil, nil, fmt.Errorf("cohort limit %d reached: %w", s.maxCohorts, ErrCohortFull) + } + + c := newCohort(spec, b) + ps, err := s.admit(c, peer, open.GetAuth()) + if err != nil { + return nil, nil, err + } + s.cohorts[key] = c + s.metrics.Cohorts.Set(float64(len(s.cohorts))) + + return c, ps, nil +} + +func (s *Service) subscribe(sub *pb.Subscribe, peer swarm.Address) (*cohort, *peerStream, error) { + if len(sub.GetTopic()) != swarm.HashSize { + return nil, nil, fmt.Errorf("topic length %d: %w", len(sub.GetTopic()), ErrInvalidSpec) + } + + s.cohortsMu.Lock() + defer s.cohortsMu.Unlock() + + c, ok := s.cohorts[string(sub.GetTopic())] + if !ok { + return nil, nil, ErrUnknownTopic + } + ps, err := s.admit(c, peer, sub.GetAuth()) + if err != nil { + return nil, nil, err + } + return c, ps, nil +} + +// admit runs the role checks for a peer joining c and, on success, creates +// and registers its peerStream in the same cohortsMu-held section as the +// capacity check, so admission is exact: two handshakes racing for a +// cohort's last slot can no longer both observe room and both be admitted, +// the way they could when registration happened later, in serve. The +// declared PublisherAuth is not a credential — this is an early refusal +// only; the authenticating check is the same authorization run at Publish +// time against the owner recovered from the message signature. +func (s *Service) admit(c *cohort, peer swarm.Address, auth *pb.PublisherAuth) (*peerStream, error) { + if c.count() >= s.capacity { + return nil, ErrCohortFull + } + + publisher := auth != nil + if publisher { + if err := authorizePublisher(c.spec, auth.GetOwner()); err != nil { + return nil, err + } + } else if c.spec.GetClosed() { + // closed restricts admission, not readability: the role a peer claims + // here is decided entirely by whether it sent a PublisherAuth, and the + // owner it names is unauthenticated. Anyone who knows the topic and + // any genesis publisher address — recoverable from any message they + // have ever observed — can present that address and be admitted as a + // publisher, and then read the cohort's whole stream. Publish-time + // authentication still stops them writing, so this is read access + // only. Confidentiality is payload encryption's job, not the closed + // flag's; making closed enforceable would need a challenge-response + // in the handshake, which is a wire-protocol change for SWIP-60. + return nil, ErrClosedCohort + } + + ps := newPeerStream(peer, publisher) + c.add(ps) + return ps, nil +} diff --git a/pkg/bps/cohort.go b/pkg/bps/cohort.go new file mode 100644 index 00000000000..68a046d3e7e --- /dev/null +++ b/pkg/bps/cohort.go @@ -0,0 +1,136 @@ +// 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 bps + +import ( + "bytes" + "errors" + "fmt" + "sort" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// CohortSpec is the set of genesis parameters that fully describes a cohort. +// It is fixed by the opener and immutable for the cohort's lifetime. +type CohortSpec = pb.CohortSpec + +// PoMin is the proximity constraint for implicit topic bindings. It is a +// protocol constant rather than a cohort parameter: an unset proto3 uint32 is +// indistinguishable from 0, which would silently disable the constraint. +// Unused until the implicit bindings are implemented. +const PoMin = 16 + +var ( + // ErrInvalidSpec is returned for a cohort spec that is structurally invalid. + ErrInvalidSpec = errors.New("bps: invalid cohort spec") + // ErrUnsupportedBinding is returned for a topic binding this implementation + // does not yet serve. + ErrUnsupportedBinding = errors.New("bps: unsupported topic binding") + // ErrUnsupportedRegime is returned for a publisher regime this + // implementation does not yet serve. + ErrUnsupportedRegime = errors.New("bps: unsupported publisher regime") +) + +// ValidateSpec checks a cohort spec for structural validity and for features +// this implementation supports. Enum zero values are invalid on the wire, so +// an unset binding or regime is an error, never a default. +func ValidateSpec(spec *pb.CohortSpec) error { + if spec == nil { + return fmt.Errorf("nil spec: %w", ErrInvalidSpec) + } + if len(spec.GetTopic()) != swarm.HashSize { + return fmt.Errorf("topic length %d: %w", len(spec.GetTopic()), ErrInvalidSpec) + } + + switch spec.GetBinding() { + case pb.TopicBinding_ANCHOR, pb.TopicBinding_FEED_TOPIC: + case pb.TopicBinding_TOPIC_BINDING_UNSPECIFIED: + return fmt.Errorf("unset binding: %w", ErrInvalidSpec) + case pb.TopicBinding_SOC_ID, pb.TopicBinding_OWNER: + return fmt.Errorf("binding %s: %w", spec.GetBinding(), ErrUnsupportedBinding) + default: + return fmt.Errorf("binding %d: %w", spec.GetBinding(), ErrInvalidSpec) + } + + switch spec.GetPublishers() { + case pb.PublisherRegime_EXPLICIT_SINGLE: + if len(spec.GetPublisherList()) != 0 { + return fmt.Errorf("publisher list set under explicit single: %w", ErrInvalidSpec) + } + case pb.PublisherRegime_EXPLICIT_LIST: + case pb.PublisherRegime_PUBLISHER_REGIME_UNSPECIFIED: + return fmt.Errorf("unset publisher regime: %w", ErrInvalidSpec) + case pb.PublisherRegime_IMPLICIT, pb.PublisherRegime_ALL: + return fmt.Errorf("regime %s: %w", spec.GetPublishers(), ErrUnsupportedRegime) + default: + return fmt.Errorf("regime %d: %w", spec.GetPublishers(), ErrInvalidSpec) + } + + // Both supported regimes are explicit, so an admin is mandatory. + if len(spec.GetAdmin()) != crypto.AddressSize { + return fmt.Errorf("admin length %d: %w", len(spec.GetAdmin()), ErrInvalidSpec) + } + for i, p := range spec.GetPublisherList() { + if len(p) != crypto.AddressSize { + return fmt.Errorf("publisher %d length %d: %w", i, len(p), ErrInvalidSpec) + } + } + + if spec.GetHistory() { + return fmt.Errorf("history delivery: %w", ErrInvalidSpec) + } + + return nil +} + +// SpecEqual reports whether two cohort specs describe the same cohort. The +// publisher list is compared as a set: two clients assembling a cohort from the +// same invite may order it differently, and SWIP-60's idempotent Open must +// treat those as identical. +func SpecEqual(a, b *pb.CohortSpec) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if !bytes.Equal(a.GetTopic(), b.GetTopic()) || + a.GetBinding() != b.GetBinding() || + a.GetPublishers() != b.GetPublishers() || + a.GetHistory() != b.GetHistory() || + a.GetClosed() != b.GetClosed() || + !bytes.Equal(a.GetAdmin(), b.GetAdmin()) || + len(a.GetPublisherList()) != len(b.GetPublisherList()) { + return false + } + + as := sortedCopy(a.GetPublisherList()) + bs := sortedCopy(b.GetPublisherList()) + for i := range as { + if !bytes.Equal(as[i], bs[i]) { + return false + } + } + return true +} + +// Publishers returns the cohort's genesis publisher set: the admin followed by +// the publisher list. Meaningful only under explicit regimes. +func Publishers(spec *pb.CohortSpec) [][]byte { + if spec == nil || len(spec.GetAdmin()) == 0 { + return nil + } + out := make([][]byte, 0, 1+len(spec.GetPublisherList())) + out = append(out, spec.GetAdmin()) + out = append(out, spec.GetPublisherList()...) + return out +} + +func sortedCopy(in [][]byte) [][]byte { + out := make([][]byte, len(in)) + copy(out, in) + sort.Slice(out, func(i, j int) bool { return bytes.Compare(out[i], out[j]) < 0 }) + return out +} diff --git a/pkg/bps/cohort_test.go b/pkg/bps/cohort_test.go new file mode 100644 index 00000000000..309c0184884 --- /dev/null +++ b/pkg/bps/cohort_test.go @@ -0,0 +1,147 @@ +// 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 bps_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +func addr(b byte) []byte { return bytes.Repeat([]byte{b}, 20) } +func topic(b byte) []byte { return bytes.Repeat([]byte{b}, swarm.HashSize) } + +func validSpec() *pb.CohortSpec { + return &pb.CohortSpec{ + Topic: topic(0xaa), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_LIST, + Admin: addr(0x01), + PublisherList: [][]byte{addr(0x02), addr(0x03)}, + } +} + +func TestValidateSpec(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + spec func() *pb.CohortSpec + want error + }{ + {name: "valid explicit list", spec: validSpec}, + {name: "valid explicit single", spec: func() *pb.CohortSpec { + s := validSpec() + s.Publishers = pb.PublisherRegime_EXPLICIT_SINGLE + s.PublisherList = nil + return s + }}, + {name: "nil spec", spec: func() *pb.CohortSpec { return nil }, want: bps.ErrInvalidSpec}, + {name: "unset binding", spec: func() *pb.CohortSpec { + s := validSpec() + s.Binding = pb.TopicBinding_TOPIC_BINDING_UNSPECIFIED + return s + }, want: bps.ErrInvalidSpec}, + {name: "unset regime", spec: func() *pb.CohortSpec { + s := validSpec() + s.Publishers = pb.PublisherRegime_PUBLISHER_REGIME_UNSPECIFIED + return s + }, want: bps.ErrInvalidSpec}, + {name: "short topic", spec: func() *pb.CohortSpec { + s := validSpec() + s.Topic = s.Topic[:16] + return s + }, want: bps.ErrInvalidSpec}, + {name: "missing admin under explicit regime", spec: func() *pb.CohortSpec { + s := validSpec() + s.Admin = nil + return s + }, want: bps.ErrInvalidSpec}, + {name: "publisher list under explicit single", spec: func() *pb.CohortSpec { + s := validSpec() + s.Publishers = pb.PublisherRegime_EXPLICIT_SINGLE + return s + }, want: bps.ErrInvalidSpec}, + {name: "malformed publisher address", spec: func() *pb.CohortSpec { + s := validSpec() + s.PublisherList = [][]byte{addr(0x02)[:10]} + return s + }, want: bps.ErrInvalidSpec}, + {name: "unsupported binding", spec: func() *pb.CohortSpec { + s := validSpec() + s.Binding = pb.TopicBinding_OWNER + return s + }, want: bps.ErrUnsupportedBinding}, + {name: "unsupported regime", spec: func() *pb.CohortSpec { + s := validSpec() + s.Publishers = pb.PublisherRegime_ALL + s.Admin = nil + s.PublisherList = nil + return s + }, want: bps.ErrUnsupportedRegime}, + {name: "history unsupported", spec: func() *pb.CohortSpec { + s := validSpec() + s.History = true + return s + }, want: bps.ErrInvalidSpec}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := bps.ValidateSpec(tc.spec()) + if tc.want == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("got %v want %v", err, tc.want) + } + }) + } +} + +func TestSpecEqual(t *testing.T) { + t.Parallel() + + reordered := validSpec() + reordered.PublisherList = [][]byte{addr(0x03), addr(0x02)} + + differentAdmin := validSpec() + differentAdmin.Admin = addr(0x09) + + differentClosed := validSpec() + differentClosed.Closed = true + + shorterList := validSpec() + shorterList.PublisherList = [][]byte{addr(0x02)} + + for _, tc := range []struct { + name string + a, b *pb.CohortSpec + want bool + }{ + {name: "identical", a: validSpec(), b: validSpec(), want: true}, + {name: "publisher list order insensitive", a: validSpec(), b: reordered, want: true}, + {name: "different admin", a: validSpec(), b: differentAdmin}, + {name: "different closed flag", a: validSpec(), b: differentClosed}, + {name: "different list length", a: validSpec(), b: shorterList}, + {name: "both nil", a: nil, b: nil, want: true}, + {name: "one nil", a: validSpec(), b: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := bps.SpecEqual(tc.a, tc.b); got != tc.want { + t.Fatalf("got %v want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/bps/export_test.go b/pkg/bps/export_test.go new file mode 100644 index 00000000000..9b4299ba2a1 --- /dev/null +++ b/pkg/bps/export_test.go @@ -0,0 +1,42 @@ +// 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 bps + +import ( + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/soc" +) + +// Binding exposes the unexported binding interface to tests, with exported +// method names. +type Binding interface { + Qualifies(spec *pb.CohortSpec, s *soc.SOC) error + DedupKey(s *soc.SOC) ([]byte, error) +} + +type exportedBinding struct{ b binding } + +func (e exportedBinding) Qualifies(spec *pb.CohortSpec, s *soc.SOC) error { + return e.b.qualifies(spec, s) +} + +func (e exportedBinding) DedupKey(s *soc.SOC) ([]byte, error) { + return e.b.dedupKey(s) +} + +// BindingFor exposes bindingFor to tests. +func BindingFor(b pb.TopicBinding) (Binding, error) { + bb, err := bindingFor(b) + if err != nil { + return nil, err + } + return exportedBinding{b: bb}, nil +} + +// AuthorizePublisher exposes authorizePublisher to tests. +var AuthorizePublisher = authorizePublisher + +// StatusOf exposes statusOf to tests. +var StatusOf = statusOf diff --git a/pkg/bps/frame.go b/pkg/bps/frame.go new file mode 100644 index 00000000000..407dd61b02d --- /dev/null +++ b/pkg/bps/frame.go @@ -0,0 +1,101 @@ +// 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 bps implements the Broadcast Pub/Sub protocol specified in SWIP-60: +// a brokered, single-hop broadcast protocol carrying single-owner chunks over +// long-lived per-topic p2p streams. +// +// Every message is a single-owner chunk verified end to end by its receiver +// against the cohort spec, so a broker can withhold messages but never forge +// one. Read access is not protected to the same standard: a cohort's closed +// flag restricts admission only, and provides no confidentiality against a +// party that knows the topic and any one publisher address — both of which are +// recoverable from any message it has observed. Payloads that must stay +// private have to be encrypted by the application. +package bps + +import ( + "bytes" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/cac" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +var ( + // ErrMalformedSoc is returned when a wire Soc has fields of the wrong size + // or a payload that is not a valid content-addressed chunk. + ErrMalformedSoc = errors.New("bps: malformed soc") + // ErrOwnerMismatch is returned when the owner declared on the wire is not + // the owner recovered from the signature. + ErrOwnerMismatch = errors.New("bps: soc owner does not match signature") +) + +// SocToProto converts a single-owner chunk to its wire representation. The +// wrapped chunk's span and payload are carried separately, per SWIP-60. +// The returned message's byte slices alias the source SOC's buffers and must +// be treated as read-only; callers must not mutate them, and must copy first +// if they need to retain them beyond the source SOC's lifetime. +func SocToProto(s *soc.SOC) (*pb.Soc, error) { + data := s.WrappedChunk().Data() + if len(data) < swarm.SpanSize { + return nil, fmt.Errorf("wrapped chunk too short: %w", ErrMalformedSoc) + } + return &pb.Soc{ + Id: s.ID(), + Owner: s.OwnerAddress(), + Signature: s.Signature(), + Span: data[:swarm.SpanSize], + Payload: data[swarm.SpanSize:], + }, nil +} + +// SocFromProto rebuilds a single-owner chunk from its wire representation and +// verifies it structurally: the owner recovered from the signature must equal +// the owner declared on the wire. A SOC returned from this function has an +// authenticated owner. +func SocFromProto(m *pb.Soc) (*soc.SOC, error) { + switch { + case len(m.GetId()) != swarm.HashSize: + return nil, fmt.Errorf("id length %d: %w", len(m.GetId()), ErrMalformedSoc) + case len(m.GetOwner()) != crypto.AddressSize: + return nil, fmt.Errorf("owner length %d: %w", len(m.GetOwner()), ErrMalformedSoc) + case len(m.GetSignature()) != swarm.SocSignatureSize: + return nil, fmt.Errorf("signature length %d: %w", len(m.GetSignature()), ErrMalformedSoc) + case len(m.GetSpan()) != swarm.SpanSize: + return nil, fmt.Errorf("span length %d: %w", len(m.GetSpan()), ErrMalformedSoc) + case len(m.GetPayload()) > swarm.ChunkSize: + return nil, fmt.Errorf("payload length %d: %w", len(m.GetPayload()), ErrMalformedSoc) + } + + wrapped := make([]byte, 0, len(m.GetSpan())+len(m.GetPayload())) + wrapped = append(wrapped, m.GetSpan()...) + wrapped = append(wrapped, m.GetPayload()...) + if _, err := cac.NewWithDataSpan(wrapped); err != nil { + return nil, fmt.Errorf("wrapped chunk: %w: %w", ErrMalformedSoc, err) + } + + addr, err := soc.CreateAddress(m.GetId(), m.GetOwner()) + if err != nil { + return nil, fmt.Errorf("soc address: %w: %w", ErrMalformedSoc, err) + } + + data := make([]byte, 0, swarm.HashSize+swarm.SocSignatureSize+len(wrapped)) + data = append(data, m.GetId()...) + data = append(data, m.GetSignature()...) + data = append(data, wrapped...) + + s, err := soc.FromChunk(swarm.NewChunk(addr, data)) + if err != nil { + return nil, fmt.Errorf("soc from chunk: %w: %w", ErrMalformedSoc, err) + } + if !bytes.Equal(s.OwnerAddress(), m.GetOwner()) { + return nil, ErrOwnerMismatch + } + return s, nil +} diff --git a/pkg/bps/frame_test.go b/pkg/bps/frame_test.go new file mode 100644 index 00000000000..da51f3bd0f9 --- /dev/null +++ b/pkg/bps/frame_test.go @@ -0,0 +1,114 @@ +// 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 bps_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +func TestSocProtoRoundTrip(t *testing.T) { + t.Parallel() + + signer, owner := bpstesting.NewSigner(t) + id := bytes.Repeat([]byte{0x01}, swarm.HashSize) + s := bpstesting.AnchorSOC(t, signer, id, []byte("hello bps")) + + m, err := bps.SocToProto(s) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(m.Owner, owner) { + t.Fatalf("owner: got %x want %x", m.Owner, owner) + } + if len(m.Span) != swarm.SpanSize { + t.Fatalf("span length: got %d want %d", len(m.Span), swarm.SpanSize) + } + + got, err := bps.SocFromProto(m) + if err != nil { + t.Fatal(err) + } + + wantAddr, err := s.Address() + if err != nil { + t.Fatal(err) + } + gotAddr, err := got.Address() + if err != nil { + t.Fatal(err) + } + if !gotAddr.Equal(wantAddr) { + t.Fatalf("address: got %s want %s", gotAddr, wantAddr) + } + if !bytes.Equal(got.WrappedChunk().Data(), s.WrappedChunk().Data()) { + t.Fatal("wrapped chunk data mismatch") + } +} + +func TestSocFromProtoRejectsForgedOwner(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + _, other := bpstesting.NewSigner(t) + id := bytes.Repeat([]byte{0x02}, swarm.HashSize) + s := bpstesting.AnchorSOC(t, signer, id, []byte("forged")) + + m, err := bps.SocToProto(s) + if err != nil { + t.Fatal(err) + } + m.Owner = other + + if _, err := bps.SocFromProto(m); !errors.Is(err, bps.ErrOwnerMismatch) { + t.Fatalf("got %v want %v", err, bps.ErrOwnerMismatch) + } +} + +func TestSocFromProtoRejectsMalformed(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + mut func(m *pbSocFields) + }{ + {name: "short id", mut: func(m *pbSocFields) { m.id = m.id[:16] }}, + {name: "short owner", mut: func(m *pbSocFields) { m.owner = m.owner[:10] }}, + {name: "short signature", mut: func(m *pbSocFields) { m.signature = m.signature[:32] }}, + {name: "short span", mut: func(m *pbSocFields) { m.span = m.span[:4] }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + signer, _ := bpstesting.NewSigner(t) + id := bytes.Repeat([]byte{0x03}, swarm.HashSize) + s := bpstesting.AnchorSOC(t, signer, id, []byte("malformed")) + m, err := bps.SocToProto(s) + if err != nil { + t.Fatal(err) + } + + f := &pbSocFields{id: m.Id, owner: m.Owner, signature: m.Signature, span: m.Span} + tc.mut(f) + m.Id, m.Owner, m.Signature, m.Span = f.id, f.owner, f.signature, f.span + + if _, err := bps.SocFromProto(m); !errors.Is(err, bps.ErrMalformedSoc) { + t.Fatalf("got %v want %v", err, bps.ErrMalformedSoc) + } + }) + } +} + +type pbSocFields struct { + id []byte + owner []byte + signature []byte + span []byte +} diff --git a/pkg/bps/handshake_test.go b/pkg/bps/handshake_test.go new file mode 100644 index 00000000000..3af3db0da1d --- /dev/null +++ b/pkg/bps/handshake_test.go @@ -0,0 +1,341 @@ +// 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 bps_test + +import ( + "context" + "sync" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/p2p" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/p2p/streamtest" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// newBroker returns a broker service and a streamer that routes to it. +func newBroker(t *testing.T, o bps.Options) (*bps.Service, p2p.Streamer, swarm.Address) { + t.Helper() + + brokerAddr := swarm.MustParseHexAddress("ca11ab1e") + broker := bps.New(nil, log.Noop, o) + t.Cleanup(func() { + if err := broker.Close(); err != nil { + t.Fatal(err) + } + }) + recorder := streamtest.New( + streamtest.WithProtocols(broker.Protocol()), + streamtest.WithBaseAddr(brokerAddr), + ) + return broker, recorder, brokerAddr +} + +// handshake writes one Hello and reads the Ack, on a fresh stream. +func handshake(t *testing.T, streamer p2p.Streamer, peer swarm.Address, hello *pb.Hello) *pb.Ack { + t.Helper() + + ctx := context.Background() + stream, err := streamer.NewStream(ctx, peer, nil, bps.ProtocolName, bps.ProtocolVersion, bps.StreamName) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stream.Reset() }) + + w, r := protobuf.NewWriterAndReader(stream) + if err := w.WriteMsgWithContext(ctx, hello); err != nil { + t.Fatal(err) + } + var ack pb.Ack + if err := r.ReadMsgWithContext(ctx, &ack); err != nil { + t.Fatal(err) + } + return &ack +} + +// rawStream opens a stream to the broker, completes the handshake by hand and +// hands the still-open stream back, so a test can go on speaking the wire +// protocol itself — sending frames a well-behaved Session would refuse to +// send, or refusing to read what the broker sends back. +func rawStream(t *testing.T, streamer p2p.Streamer, peer swarm.Address, hello *pb.Hello) (p2p.Stream, protobuf.Writer, protobuf.Reader, *pb.Ack) { + t.Helper() + + ctx := context.Background() + stream, err := streamer.NewStream(ctx, peer, nil, bps.ProtocolName, bps.ProtocolVersion, bps.StreamName) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stream.Reset() }) + + w, r := protobuf.NewWriterAndReader(stream) + if err := w.WriteMsgWithContext(ctx, hello); err != nil { + t.Fatal(err) + } + var ack pb.Ack + if err := r.ReadMsgWithContext(ctx, &ack); err != nil { + t.Fatal(err) + } + return stream, w, r, &ack +} + +func openHello(spec *pb.CohortSpec, auth *pb.PublisherAuth) *pb.Hello { + return &pb.Hello{Handshake: &pb.Hello_Open{Open: &pb.Open{Cohort: spec, Auth: auth}}} +} + +func subscribeHello(topic []byte, auth *pb.PublisherAuth) *pb.Hello { + return &pb.Hello{Handshake: &pb.Hello_Subscribe{Subscribe: &pb.Subscribe{Topic: topic, Auth: auth}}} +} + +func TestHandshakeOpen(t *testing.T) { + t.Parallel() + + broker, streamer, brokerAddr := newBroker(t, bps.Options{}) + spec := validSpec() + + ack := handshake(t, streamer, brokerAddr, openHello(spec, &pb.PublisherAuth{Owner: spec.Admin})) + if ack.Status != pb.Status_OK { + t.Fatalf("status: got %s want OK", ack.Status) + } + if !bps.SpecEqual(ack.Cohort, spec) { + t.Fatal("Ack did not echo the cohort spec") + } + if got := broker.Topics(); len(got) != 1 || !got[0].Equal(swarm.NewAddress(spec.Topic)) { + t.Fatalf("topics: got %v", got) + } +} + +func TestHandshakeOpenIsIdempotent(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + spec := validSpec() + auth := &pb.PublisherAuth{Owner: spec.Admin} + + if ack := handshake(t, streamer, brokerAddr, openHello(spec, auth)); ack.Status != pb.Status_OK { + t.Fatalf("first open: got %s want OK", ack.Status) + } + // An identical spec is equivalent to Subscribe. + if ack := handshake(t, streamer, brokerAddr, openHello(validSpec(), auth)); ack.Status != pb.Status_OK { + t.Fatalf("idempotent open: got %s want OK", ack.Status) + } + + mismatched := validSpec() + mismatched.Closed = true + if ack := handshake(t, streamer, brokerAddr, openHello(mismatched, auth)); ack.Status != pb.Status_REJECTED { + t.Fatalf("mismatched open: got %s want REJECTED", ack.Status) + } +} + +func TestHandshakeSubscribe(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + spec := validSpec() + + if ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)); ack.Status != pb.Status_UNKNOWN_TOPIC { + t.Fatalf("unknown topic: got %s want UNKNOWN_TOPIC", ack.Status) + } + + if ack := handshake(t, streamer, brokerAddr, openHello(spec, &pb.PublisherAuth{Owner: spec.Admin})); ack.Status != pb.Status_OK { + t.Fatal("open rejected") + } + + ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)) + if ack.Status != pb.Status_OK { + t.Fatalf("subscribe: got %s want OK", ack.Status) + } + if !bps.SpecEqual(ack.Cohort, spec) { + t.Fatal("Ack did not echo the cohort spec to the subscriber") + } +} + +func TestHandshakeRejections(t *testing.T) { + t.Parallel() + + closedSpec := validSpec() + closedSpec.Closed = true + + t.Run("closed cohort refuses a non-publisher", func(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + if ack := handshake(t, streamer, brokerAddr, openHello(closedSpec, &pb.PublisherAuth{Owner: closedSpec.Admin})); ack.Status != pb.Status_OK { + t.Fatal("open rejected") + } + if ack := handshake(t, streamer, brokerAddr, subscribeHello(closedSpec.Topic, nil)); ack.Status != pb.Status_REJECTED { + t.Fatalf("got %s want REJECTED", ack.Status) + } + }) + + t.Run("publisher outside the genesis list", func(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + spec := validSpec() + if ack := handshake(t, streamer, brokerAddr, openHello(spec, &pb.PublisherAuth{Owner: spec.Admin})); ack.Status != pb.Status_OK { + t.Fatal("open rejected") + } + if ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, &pb.PublisherAuth{Owner: addr(0xee)})); ack.Status != pb.Status_REJECTED { + t.Fatalf("got %s want REJECTED", ack.Status) + } + }) + + t.Run("invalid spec", func(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + bad := validSpec() + bad.Binding = pb.TopicBinding_TOPIC_BINDING_UNSPECIFIED + if ack := handshake(t, streamer, brokerAddr, openHello(bad, nil)); ack.Status != pb.Status_REJECTED { + t.Fatalf("got %s want REJECTED", ack.Status) + } + }) + + t.Run("empty hello", func(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{}) + if ack := handshake(t, streamer, brokerAddr, &pb.Hello{}); ack.Status != pb.Status_REJECTED { + t.Fatalf("got %s want REJECTED", ack.Status) + } + }) +} + +func TestHandshakeCapacity(t *testing.T) { + t.Parallel() + + _, streamer, brokerAddr := newBroker(t, bps.Options{Capacity: 2}) + spec := validSpec() + auth := &pb.PublisherAuth{Owner: spec.Admin} + + // The opener takes the first slot. + if ack := handshake(t, streamer, brokerAddr, openHello(spec, auth)); ack.Status != pb.Status_OK { + t.Fatalf("open: got %s want OK", ack.Status) + } + // The second peer takes the last slot. + if ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)); ack.Status != pb.Status_OK { + t.Fatalf("second peer: got %s want OK", ack.Status) + } + // The third is refused with FULL — and nothing else. A singlehop broker + // never refers. + ack := handshake(t, streamer, brokerAddr, subscribeHello(spec.Topic, nil)) + if ack.Status != pb.Status_FULL { + t.Fatalf("third peer: got %s want FULL", ack.Status) + } + if ack.Cohort != nil { + t.Fatal("a refusal must not echo the cohort spec") + } + + // An Open at capacity is refused the same way. + if ack := handshake(t, streamer, brokerAddr, openHello(validSpec(), auth)); ack.Status != pb.Status_FULL { + t.Fatalf("open at capacity: got %s want FULL", ack.Status) + } +} + +// TestHandshakeCohortLimit pins the registry cap: cohorts are never reclaimed, +// so the number a peer can make a broker fix has to be bounded. Joining a +// cohort that already exists is deliberately unaffected. +func TestHandshakeCohortLimit(t *testing.T) { + t.Parallel() + + broker, streamer, brokerAddr := newBroker(t, bps.Options{MaxCohorts: 1}) + first := validSpec() + auth := &pb.PublisherAuth{Owner: first.Admin} + + if ack := handshake(t, streamer, brokerAddr, openHello(first, auth)); ack.Status != pb.Status_OK { + t.Fatalf("first open: got %s want OK", ack.Status) + } + + second := validSpec() + second.Topic = topic(0xbb) + if ack := handshake(t, streamer, brokerAddr, openHello(second, auth)); ack.Status != pb.Status_FULL { + t.Fatalf("open beyond the limit: got %s want FULL", ack.Status) + } + if got := broker.Topics(); len(got) != 1 { + t.Fatalf("topics: got %d want 1 — a refused Open registered a cohort", len(got)) + } + + // The existing cohort still admits peers, by Open and by Subscribe alike. + if ack := handshake(t, streamer, brokerAddr, openHello(validSpec(), auth)); ack.Status != pb.Status_OK { + t.Fatalf("idempotent open at the limit: got %s want OK", ack.Status) + } + if ack := handshake(t, streamer, brokerAddr, subscribeHello(first.Topic, nil)); ack.Status != pb.Status_OK { + t.Fatalf("subscribe at the limit: got %s want OK", ack.Status) + } +} + +// TestHandshakeCapacityConcurrent fires more handshakes at a cohort than its +// capacity allows, all at once, and asserts exactly capacity-1 of them are +// admitted (one slot is already taken by the synchronous opener below). If +// the capacity check and the peer's registration are not atomic under the +// same lock, concurrent handshakes can all observe room for the last slot +// and all be admitted, over-subscribing the cohort. +func TestHandshakeCapacityConcurrent(t *testing.T) { + t.Parallel() + + const capacity = 4 + const attempts = 20 + + _, streamer, brokerAddr := newBroker(t, bps.Options{Capacity: capacity}) + spec := validSpec() + auth := &pb.PublisherAuth{Owner: spec.Admin} + + // The opener takes the first slot synchronously, so the concurrent + // subscribers below race for exactly the remaining capacity-1 slots. + if ack := handshake(t, streamer, brokerAddr, openHello(spec, auth)); ack.Status != pb.Status_OK { + t.Fatalf("open: got %s want OK", ack.Status) + } + + var wg sync.WaitGroup + statuses := make([]pb.Status, attempts) + for i := range attempts { + wg.Add(1) + go func(i int) { + defer wg.Done() + + ctx := context.Background() + stream, err := streamer.NewStream(ctx, brokerAddr, nil, bps.ProtocolName, bps.ProtocolVersion, bps.StreamName) + if err != nil { + t.Errorf("new stream: %v", err) + return + } + defer func() { _ = stream.Reset() }() + + w, r := protobuf.NewWriterAndReader(stream) + if err := w.WriteMsgWithContext(ctx, subscribeHello(spec.Topic, nil)); err != nil { + t.Errorf("write hello: %v", err) + return + } + var ack pb.Ack + if err := r.ReadMsgWithContext(ctx, &ack); err != nil { + t.Errorf("read ack: %v", err) + return + } + statuses[i] = ack.Status + }(i) + } + wg.Wait() + + var admitted, full int + for _, status := range statuses { + switch status { + case pb.Status_OK: + admitted++ + case pb.Status_FULL: + full++ + default: + t.Fatalf("unexpected status %s", status) + } + } + if want := capacity - 1; admitted != want { + t.Fatalf("admitted: got %d want %d — capacity was not enforced atomically", admitted, want) + } + if want := attempts - (capacity - 1); full != want { + t.Fatalf("refused: got %d want %d", full, want) + } +} diff --git a/pkg/bps/hostile_test.go b/pkg/bps/hostile_test.go new file mode 100644 index 00000000000..b1e041a0961 --- /dev/null +++ b/pkg/bps/hostile_test.go @@ -0,0 +1,305 @@ +// 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 bps_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/p2p" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/p2p/streamtest" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// hostileBroker is a hand-driven broker: it answers the Hello with whatever +// ack says, writes frames, and then ends the stream. It speaks the wire +// protocol directly rather than going through Service, which is the only way +// to send a client something a correct broker would never send. +type hostileBroker struct { + ack func(hello *pb.Hello) *pb.Ack + frames []*pb.Broadcast +} + +func (h hostileBroker) handler(ctx context.Context, _ p2p.Peer, stream p2p.Stream) error { + w, r := protobuf.NewWriterAndReader(stream) + + var hello pb.Hello + if err := r.ReadMsgWithContext(ctx, &hello); err != nil { + _ = stream.Reset() + return err + } + if err := w.WriteMsgWithContext(ctx, h.ack(&hello)); err != nil { + _ = stream.Reset() + return err + } + for _, f := range h.frames { + if err := w.WriteMsgWithContext(ctx, f); err != nil { + _ = stream.Reset() + return err + } + } + // Ending the stream lets the client's read loop observe EOF and close its + // message channel, so a test can tell "nothing was delivered" apart from + // "nothing has been delivered yet". + return stream.FullClose() +} + +// newHostileClient returns a client service whose streamer routes to h, and +// the address to dial. +func newHostileClient(t *testing.T, h hostileBroker) (*bps.Service, swarm.Address) { + t.Helper() + + brokerAddr := swarm.MustParseHexAddress("bada55") + recorder := streamtest.New( + streamtest.WithProtocols(p2p.ProtocolSpec{ + Name: bps.ProtocolName, + Version: bps.ProtocolVersion, + StreamSpecs: []p2p.StreamSpec{ + {Name: bps.StreamName, Handler: h.handler}, + }, + }), + streamtest.WithBaseAddr(brokerAddr), + ) + client := bps.New(recorder, log.Noop, bps.Options{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("close client: %v", err) + } + }) + return client, brokerAddr +} + +func socFrame(t *testing.T, s *soc.SOC) *pb.Broadcast { + t.Helper() + + m, err := bps.SocToProto(s) + if err != nil { + t.Fatal(err) + } + return &pb.Broadcast{Frame: &pb.Broadcast_Soc{Soc: m}} +} + +// drained collects everything a session delivers until its channel closes, or +// the deadline passes. +func drained(t *testing.T, ss *bps.Session) []*soc.SOC { + t.Helper() + + var out []*soc.SOC + deadline := time.After(5 * time.Second) + for { + select { + case s, ok := <-ss.Messages(): + if !ok { + return out + } + out = append(out, s) + case <-deadline: + t.Fatal("timed out waiting for the session to end") + return nil + } + } +} + +// TestHostileBrokerCannotForge is the protocol's headline claim: a broker can +// withhold messages but never forge one. Every message a subscriber accepts is +// verified end to end against the cohort spec, so neither of the two things a +// hostile broker can attempt under an explicit publisher regime — signing +// with a key outside the publisher set, and lying about a SOC's owner — +// reaches the consumer. (A third historical attack, sending a SOC that is not +// the cohort's anchor, is no longer one: under explicit regimes SWIP-60's +// anchor binding does not check the SOC address against the topic, because +// the id does no protocol work there — see TestAnchorMnemonicExplicitList and +// TestHostileBrokerDeliversOffAnchorMessage.) +func TestHostileBrokerCannotForge(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // The honest cohort: one publisher, ANCHOR-bound under EXPLICIT_SINGLE. + spec, _, genuine := anchorCohort(t, topic(0xa1), []byte("genuine")) + + // (a) a message signed by a key outside the publisher set. + outsider, _ := bpstesting.NewSigner(t) + outsiderMsg := bpstesting.AnchorSOC(t, outsider, topic(0xa1), []byte("outsider")) + + // (b) a message whose declared owner is not the owner recovered from its + // signature: the genuine message with the owner field swapped out. + forgedOwner := socFrame(t, genuine) + _, outsiderOwner := bpstesting.NewSigner(t) + forgedOwner.GetSoc().Owner = outsiderOwner + + client, brokerAddr := newHostileClient(t, hostileBroker{ + ack: func(*pb.Hello) *pb.Ack { + return &pb.Ack{Status: pb.Status_OK, Cohort: spec} + }, + frames: []*pb.Broadcast{ + socFrame(t, outsiderMsg), + forgedOwner, + }, + }) + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if got := drained(t, sub); len(got) != 0 { + t.Fatalf("hostile broker forged %d message(s) past end-to-end verification", len(got)) + } +} + +// TestHostileBrokerDeliversOffAnchorMessage is the companion to the test +// above: under an explicit publisher regime, a message from the cohort's +// legitimate publisher that is not the cohort's anchor (same owner, different +// id, therefore a different SOC address) is not a forgery — it qualifies, per +// SWIP-60's relaxation of the ANCHOR binding — and does reach the consumer. +func TestHostileBrokerDeliversOffAnchorMessage(t *testing.T) { + t.Parallel() + + ctx := context.Background() + spec, signer, _ := anchorCohort(t, topic(0xa4), []byte("genuine")) + offAnchor := bpstesting.AnchorSOC(t, signer, topic(0xa5), []byte("off anchor")) + + client, brokerAddr := newHostileClient(t, hostileBroker{ + ack: func(*pb.Hello) *pb.Ack { + return &pb.Ack{Status: pb.Status_OK, Cohort: spec} + }, + frames: []*pb.Broadcast{socFrame(t, offAnchor)}, + }) + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + got := drained(t, sub) + if len(got) != 1 { + t.Fatalf("delivered %d messages, want 1", len(got)) + } + if !got[0].WrappedChunk().Address().Equal(offAnchor.WrappedChunk().Address()) { + t.Fatal("delivered the wrong message") + } +} + +// TestHostileBrokerDeliversGenuineMessage is the control for the test above: +// the same hand-driven broker, sending a message that really is signed by the +// cohort's publisher and really is the anchor, does get through. Without it, +// the forgery test would pass just as well against a client that dropped +// everything. +func TestHostileBrokerDeliversGenuineMessage(t *testing.T) { + t.Parallel() + + ctx := context.Background() + spec, _, genuine := anchorCohort(t, topic(0xa3), []byte("genuine")) + + client, brokerAddr := newHostileClient(t, hostileBroker{ + ack: func(*pb.Hello) *pb.Ack { + return &pb.Ack{Status: pb.Status_OK, Cohort: spec} + }, + frames: []*pb.Broadcast{socFrame(t, genuine)}, + }) + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + got := drained(t, sub) + if len(got) != 1 { + t.Fatalf("delivered %d messages, want 1", len(got)) + } + if !got[0].WrappedChunk().Address().Equal(genuine.WrappedChunk().Address()) { + t.Fatal("delivered the wrong message") + } +} + +// TestOpenRefusesTamperedSpecEcho pins that a client that asked for a specific +// cohort keeps the spec it asked for: the echoed spec is compared field for +// field and a broker that substitutes one is refused, rather than having its +// version adopted as the rule every later message is verified against. +func TestOpenRefusesTamperedSpecEcho(t *testing.T) { + t.Parallel() + + ctx := context.Background() + spec, _, _ := anchorCohort(t, topic(0xa4), []byte("tampered echo")) + + tampered := &pb.CohortSpec{ + Topic: spec.Topic, + Binding: spec.Binding, + Publishers: spec.Publishers, + Admin: spec.Admin, + Closed: true, + } + + client, brokerAddr := newHostileClient(t, hostileBroker{ + ack: func(*pb.Hello) *pb.Ack { + return &pb.Ack{Status: pb.Status_OK, Cohort: tampered} + }, + }) + + _, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if !errors.Is(err, bps.ErrSpecMismatch) { + t.Fatalf("got %v want %v", err, bps.ErrSpecMismatch) + } + + // Subscribe cannot make the same check: it has nothing to compare the + // echo against, and so accepts it. This is the documented asymmetry, not + // an oversight — under ANCHOR the topic pins the owner regardless of what + // the spec claims. + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + if !sub.Spec().GetClosed() { + t.Fatal("expected Subscribe to adopt the broker's echoed spec") + } +} + +// TestSessionSkipsUnknownFrame pins the forward-compatibility promise: a +// Broadcast whose oneof is unset — a control frame reserved for bps-multihop — +// is skipped rather than ending the session, so a later revision can add +// frames without a version bump. +func TestSessionSkipsUnknownFrame(t *testing.T) { + t.Parallel() + + ctx := context.Background() + spec, _, genuine := anchorCohort(t, topic(0xa5), []byte("after the unknown frame")) + + client, brokerAddr := newHostileClient(t, hostileBroker{ + ack: func(*pb.Hello) *pb.Ack { + return &pb.Ack{Status: pb.Status_OK, Cohort: spec} + }, + frames: []*pb.Broadcast{ + {}, // reserved multihop control frame + socFrame(t, genuine), + }, + }) + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + got := drained(t, sub) + if len(got) != 1 { + t.Fatalf("delivered %d messages, want 1 — the unknown frame was not skipped", len(got)) + } + if !got[0].WrappedChunk().Address().Equal(genuine.WrappedChunk().Address()) { + t.Fatal("delivered the wrong message") + } +} diff --git a/pkg/bps/metrics.go b/pkg/bps/metrics.go new file mode 100644 index 00000000000..59ce4cc422c --- /dev/null +++ b/pkg/bps/metrics.go @@ -0,0 +1,68 @@ +// 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 bps + +import ( + m "github.com/ethersphere/bee/v2/pkg/metrics" + "github.com/prometheus/client_golang/prometheus" +) + +type metrics struct { + Handshakes *prometheus.CounterVec + Cohorts prometheus.Gauge + Published prometheus.Counter + Dropped *prometheus.CounterVec + + Broadcast prometheus.Counter + Invalid prometheus.Counter +} + +func newMetrics() metrics { + subsystem := "bps" + + return metrics{ + Handshakes: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "handshakes", + Help: "Number of handshakes answered, by status.", + }, []string{"status"}), + Cohorts: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "cohorts", + Help: "Number of cohorts this node brokers.", + }), + Published: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "published", + Help: "Number of messages published by local sessions.", + }), + Dropped: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "dropped", + Help: "Number of messages dropped, by reason.", + }, []string{"reason"}), + Broadcast: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "broadcast", + Help: "Number of messages accepted by this broker and enqueued for fan-out. Peers dropped as too slow to receive them are counted in dropped{reason=slow_peer}.", + }), + Invalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: m.Namespace, + Subsystem: subsystem, + Name: "invalid", + Help: "Number of invalid messages received from publishers.", + }), + } +} + +// Metrics returns the prometheus collectors of this service. +func (s *Service) Metrics() []prometheus.Collector { + return m.PrometheusCollectorsFromFields(s.metrics) +} diff --git a/pkg/bps/mock/mock.go b/pkg/bps/mock/mock.go new file mode 100644 index 00000000000..250b4d46564 --- /dev/null +++ b/pkg/bps/mock/mock.go @@ -0,0 +1,73 @@ +// 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 mock provides a mock BPS service for testing downstream consumers. +package mock + +import ( + "context" + "errors" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// ErrNotImplemented is returned by a call the test did not configure. +var ErrNotImplemented = errors.New("bps mock: not implemented") + +type ( + openFunc func(context.Context, swarm.Address, *pb.CohortSpec, *pb.PublisherAuth) (bps.Publisher, error) + subscribeFunc func(context.Context, swarm.Address, swarm.Address, *pb.PublisherAuth) (bps.Publisher, error) +) + +// Service is a mock BPS service. +type Service struct { + open openFunc + subscribe subscribeFunc +} + +// Option configures the mock. +type Option interface { + apply(*Service) +} + +type optionFunc func(*Service) + +func (f optionFunc) apply(s *Service) { f(s) } + +// WithOpenFunc sets the function called by Open. +func WithOpenFunc(f openFunc) Option { + return optionFunc(func(s *Service) { s.open = f }) +} + +// WithSubscribeFunc sets the function called by Subscribe. +func WithSubscribeFunc(f subscribeFunc) Option { + return optionFunc(func(s *Service) { s.subscribe = f }) +} + +// New returns a new mock service. +func New(opts ...Option) *Service { + s := new(Service) + for _, o := range opts { + o.apply(s) + } + return s +} + +// Open calls the configured open function. +func (s *Service) Open(ctx context.Context, peer swarm.Address, spec *pb.CohortSpec, auth *pb.PublisherAuth) (bps.Publisher, error) { + if s.open == nil { + return nil, ErrNotImplemented + } + return s.open(ctx, peer, spec, auth) +} + +// Subscribe calls the configured subscribe function. +func (s *Service) Subscribe(ctx context.Context, peer swarm.Address, topic swarm.Address, auth *pb.PublisherAuth) (bps.Publisher, error) { + if s.subscribe == nil { + return nil, ErrNotImplemented + } + return s.subscribe(ctx, peer, topic, auth) +} diff --git a/pkg/bps/mock/mock_test.go b/pkg/bps/mock/mock_test.go new file mode 100644 index 00000000000..2105cc9edc8 --- /dev/null +++ b/pkg/bps/mock/mock_test.go @@ -0,0 +1,47 @@ +// 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 mock_test + +import ( + "context" + "errors" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/mock" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +func TestMockOpen(t *testing.T) { + t.Parallel() + + ctx := context.Background() + want := errors.New("refused") + + svc := mock.New(mock.WithOpenFunc( + func(context.Context, swarm.Address, *pb.CohortSpec, *pb.PublisherAuth) (bps.Publisher, error) { + return nil, want + }, + )) + + if _, err := svc.Open(ctx, swarm.ZeroAddress, nil, nil); !errors.Is(err, want) { + t.Fatalf("got %v want %v", err, want) + } +} + +func TestMockDefaults(t *testing.T) { + t.Parallel() + + ctx := context.Background() + svc := mock.New() + + if _, err := svc.Open(ctx, swarm.ZeroAddress, nil, nil); !errors.Is(err, mock.ErrNotImplemented) { + t.Fatalf("open: got %v want %v", err, mock.ErrNotImplemented) + } + if _, err := svc.Subscribe(ctx, swarm.ZeroAddress, swarm.ZeroAddress, nil); !errors.Is(err, mock.ErrNotImplemented) { + t.Fatalf("subscribe: got %v want %v", err, mock.ErrNotImplemented) + } +} diff --git a/pkg/bps/pb/bps.pb.go b/pkg/bps/pb/bps.pb.go new file mode 100644 index 00000000000..5b489dc74a9 --- /dev/null +++ b/pkg/bps/pb/bps.pb.go @@ -0,0 +1,2780 @@ +// Code generated by protoc-gen-gogo. DO NOT EDIT. +// source: bps.proto + +package pb + +import ( + fmt "fmt" + proto "github.com/gogo/protobuf/proto" + io "io" + math "math" + math_bits "math/bits" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package + +// What the topic binds to (see SWIP-60: binding semantics). +type TopicBinding int32 + +const ( + TopicBinding_TOPIC_BINDING_UNSPECIFIED TopicBinding = 0 + TopicBinding_ANCHOR TopicBinding = 1 + TopicBinding_SOC_ID TopicBinding = 2 + TopicBinding_OWNER TopicBinding = 3 + TopicBinding_FEED_TOPIC TopicBinding = 4 +) + +var TopicBinding_name = map[int32]string{ + 0: "TOPIC_BINDING_UNSPECIFIED", + 1: "ANCHOR", + 2: "SOC_ID", + 3: "OWNER", + 4: "FEED_TOPIC", +} + +var TopicBinding_value = map[string]int32{ + "TOPIC_BINDING_UNSPECIFIED": 0, + "ANCHOR": 1, + "SOC_ID": 2, + "OWNER": 3, + "FEED_TOPIC": 4, +} + +func (x TopicBinding) String() string { + return proto.EnumName(TopicBinding_name, int32(x)) +} + +func (TopicBinding) EnumDescriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{0} +} + +// Who may author. +type PublisherRegime int32 + +const ( + PublisherRegime_PUBLISHER_REGIME_UNSPECIFIED PublisherRegime = 0 + PublisherRegime_EXPLICIT_SINGLE PublisherRegime = 1 + PublisherRegime_EXPLICIT_LIST PublisherRegime = 2 + PublisherRegime_IMPLICIT PublisherRegime = 3 + PublisherRegime_ALL PublisherRegime = 4 +) + +var PublisherRegime_name = map[int32]string{ + 0: "PUBLISHER_REGIME_UNSPECIFIED", + 1: "EXPLICIT_SINGLE", + 2: "EXPLICIT_LIST", + 3: "IMPLICIT", + 4: "ALL", +} + +var PublisherRegime_value = map[string]int32{ + "PUBLISHER_REGIME_UNSPECIFIED": 0, + "EXPLICIT_SINGLE": 1, + "EXPLICIT_LIST": 2, + "IMPLICIT": 3, + "ALL": 4, +} + +func (x PublisherRegime) String() string { + return proto.EnumName(PublisherRegime_name, int32(x)) +} + +func (PublisherRegime) EnumDescriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{1} +} + +type Status int32 + +const ( + Status_STATUS_UNSPECIFIED Status = 0 + Status_OK Status = 1 + Status_FULL Status = 2 + Status_UNKNOWN_TOPIC Status = 3 + Status_REJECTED Status = 4 +) + +var Status_name = map[int32]string{ + 0: "STATUS_UNSPECIFIED", + 1: "OK", + 2: "FULL", + 3: "UNKNOWN_TOPIC", + 4: "REJECTED", +} + +var Status_value = map[string]int32{ + "STATUS_UNSPECIFIED": 0, + "OK": 1, + "FULL": 2, + "UNKNOWN_TOPIC": 3, + "REJECTED": 4, +} + +func (x Status) String() string { + return proto.EnumName(Status_name, int32(x)) +} + +func (Status) EnumDescriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{2} +} + +// Fixed by the cohort's opener; immutable for the cohort's lifetime. +type CohortSpec struct { + Topic []byte `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + Binding TopicBinding `protobuf:"varint,2,opt,name=binding,proto3,enum=bps.TopicBinding" json:"binding,omitempty"` + Publishers PublisherRegime `protobuf:"varint,3,opt,name=publishers,proto3,enum=bps.PublisherRegime" json:"publishers,omitempty"` + History bool `protobuf:"varint,4,opt,name=history,proto3" json:"history,omitempty"` + Admin []byte `protobuf:"bytes,5,opt,name=admin,proto3" json:"admin,omitempty"` + PublisherList [][]byte `protobuf:"bytes,6,rep,name=publisher_list,json=publisherList,proto3" json:"publisher_list,omitempty"` + Closed bool `protobuf:"varint,8,opt,name=closed,proto3" json:"closed,omitempty"` +} + +func (m *CohortSpec) Reset() { *m = CohortSpec{} } +func (m *CohortSpec) String() string { return proto.CompactTextString(m) } +func (*CohortSpec) ProtoMessage() {} +func (*CohortSpec) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{0} +} +func (m *CohortSpec) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *CohortSpec) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_CohortSpec.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *CohortSpec) XXX_Merge(src proto.Message) { + xxx_messageInfo_CohortSpec.Merge(m, src) +} +func (m *CohortSpec) XXX_Size() int { + return m.Size() +} +func (m *CohortSpec) XXX_DiscardUnknown() { + xxx_messageInfo_CohortSpec.DiscardUnknown(m) +} + +var xxx_messageInfo_CohortSpec proto.InternalMessageInfo + +func (m *CohortSpec) GetTopic() []byte { + if m != nil { + return m.Topic + } + return nil +} + +func (m *CohortSpec) GetBinding() TopicBinding { + if m != nil { + return m.Binding + } + return TopicBinding_TOPIC_BINDING_UNSPECIFIED +} + +func (m *CohortSpec) GetPublishers() PublisherRegime { + if m != nil { + return m.Publishers + } + return PublisherRegime_PUBLISHER_REGIME_UNSPECIFIED +} + +func (m *CohortSpec) GetHistory() bool { + if m != nil { + return m.History + } + return false +} + +func (m *CohortSpec) GetAdmin() []byte { + if m != nil { + return m.Admin + } + return nil +} + +func (m *CohortSpec) GetPublisherList() [][]byte { + if m != nil { + return m.PublisherList + } + return nil +} + +func (m *CohortSpec) GetClosed() bool { + if m != nil { + return m.Closed + } + return false +} + +// Opener -> broker: the one peer that fixes the cohort. +type Open struct { + Cohort *CohortSpec `protobuf:"bytes,1,opt,name=cohort,proto3" json:"cohort,omitempty"` + Auth *PublisherAuth `protobuf:"bytes,2,opt,name=auth,proto3" json:"auth,omitempty"` +} + +func (m *Open) Reset() { *m = Open{} } +func (m *Open) String() string { return proto.CompactTextString(m) } +func (*Open) ProtoMessage() {} +func (*Open) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{1} +} +func (m *Open) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Open) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Open.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Open) XXX_Merge(src proto.Message) { + xxx_messageInfo_Open.Merge(m, src) +} +func (m *Open) XXX_Size() int { + return m.Size() +} +func (m *Open) XXX_DiscardUnknown() { + xxx_messageInfo_Open.DiscardUnknown(m) +} + +var xxx_messageInfo_Open proto.InternalMessageInfo + +func (m *Open) GetCohort() *CohortSpec { + if m != nil { + return m.Cohort + } + return nil +} + +func (m *Open) GetAuth() *PublisherAuth { + if m != nil { + return m.Auth + } + return nil +} + +// Joiner -> broker: names the topic — nothing more. +type Subscribe struct { + Topic []byte `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + Auth *PublisherAuth `protobuf:"bytes,2,opt,name=auth,proto3" json:"auth,omitempty"` +} + +func (m *Subscribe) Reset() { *m = Subscribe{} } +func (m *Subscribe) String() string { return proto.CompactTextString(m) } +func (*Subscribe) ProtoMessage() {} +func (*Subscribe) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{2} +} +func (m *Subscribe) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Subscribe) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Subscribe.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Subscribe) XXX_Merge(src proto.Message) { + xxx_messageInfo_Subscribe.Merge(m, src) +} +func (m *Subscribe) XXX_Size() int { + return m.Size() +} +func (m *Subscribe) XXX_DiscardUnknown() { + xxx_messageInfo_Subscribe.DiscardUnknown(m) +} + +var xxx_messageInfo_Subscribe proto.InternalMessageInfo + +func (m *Subscribe) GetTopic() []byte { + if m != nil { + return m.Topic + } + return nil +} + +func (m *Subscribe) GetAuth() *PublisherAuth { + if m != nil { + return m.Auth + } + return nil +} + +// Peer -> broker: the first frame on a fresh stream. Not in SWIP-60 as +// published; added because Open and Subscribe are otherwise indistinguishable +// on the wire. See the design doc. +type Hello struct { + // Types that are valid to be assigned to Handshake: + // *Hello_Open + // *Hello_Subscribe + Handshake isHello_Handshake `protobuf_oneof:"handshake"` +} + +func (m *Hello) Reset() { *m = Hello{} } +func (m *Hello) String() string { return proto.CompactTextString(m) } +func (*Hello) ProtoMessage() {} +func (*Hello) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{3} +} +func (m *Hello) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Hello) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Hello.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Hello) XXX_Merge(src proto.Message) { + xxx_messageInfo_Hello.Merge(m, src) +} +func (m *Hello) XXX_Size() int { + return m.Size() +} +func (m *Hello) XXX_DiscardUnknown() { + xxx_messageInfo_Hello.DiscardUnknown(m) +} + +var xxx_messageInfo_Hello proto.InternalMessageInfo + +type isHello_Handshake interface { + isHello_Handshake() + MarshalTo([]byte) (int, error) + Size() int +} + +type Hello_Open struct { + Open *Open `protobuf:"bytes,1,opt,name=open,proto3,oneof" json:"open,omitempty"` +} +type Hello_Subscribe struct { + Subscribe *Subscribe `protobuf:"bytes,2,opt,name=subscribe,proto3,oneof" json:"subscribe,omitempty"` +} + +func (*Hello_Open) isHello_Handshake() {} +func (*Hello_Subscribe) isHello_Handshake() {} + +func (m *Hello) GetHandshake() isHello_Handshake { + if m != nil { + return m.Handshake + } + return nil +} + +func (m *Hello) GetOpen() *Open { + if x, ok := m.GetHandshake().(*Hello_Open); ok { + return x.Open + } + return nil +} + +func (m *Hello) GetSubscribe() *Subscribe { + if x, ok := m.GetHandshake().(*Hello_Subscribe); ok { + return x.Subscribe + } + return nil +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*Hello) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*Hello_Open)(nil), + (*Hello_Subscribe)(nil), + } +} + +type PublisherAuth struct { + Owner []byte `protobuf:"bytes,1,opt,name=owner,proto3" json:"owner,omitempty"` + Id []byte `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` +} + +func (m *PublisherAuth) Reset() { *m = PublisherAuth{} } +func (m *PublisherAuth) String() string { return proto.CompactTextString(m) } +func (*PublisherAuth) ProtoMessage() {} +func (*PublisherAuth) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{4} +} +func (m *PublisherAuth) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *PublisherAuth) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_PublisherAuth.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *PublisherAuth) XXX_Merge(src proto.Message) { + xxx_messageInfo_PublisherAuth.Merge(m, src) +} +func (m *PublisherAuth) XXX_Size() int { + return m.Size() +} +func (m *PublisherAuth) XXX_DiscardUnknown() { + xxx_messageInfo_PublisherAuth.DiscardUnknown(m) +} + +var xxx_messageInfo_PublisherAuth proto.InternalMessageInfo + +func (m *PublisherAuth) GetOwner() []byte { + if m != nil { + return m.Owner + } + return nil +} + +func (m *PublisherAuth) GetId() []byte { + if m != nil { + return m.Id + } + return nil +} + +// Broker -> peer, answering Open or Subscribe. +type Ack struct { + Status Status `protobuf:"varint,1,opt,name=status,proto3,enum=bps.Status" json:"status,omitempty"` + Cohort *CohortSpec `protobuf:"bytes,2,opt,name=cohort,proto3" json:"cohort,omitempty"` +} + +func (m *Ack) Reset() { *m = Ack{} } +func (m *Ack) String() string { return proto.CompactTextString(m) } +func (*Ack) ProtoMessage() {} +func (*Ack) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{5} +} +func (m *Ack) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Ack) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Ack.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Ack) XXX_Merge(src proto.Message) { + xxx_messageInfo_Ack.Merge(m, src) +} +func (m *Ack) XXX_Size() int { + return m.Size() +} +func (m *Ack) XXX_DiscardUnknown() { + xxx_messageInfo_Ack.DiscardUnknown(m) +} + +var xxx_messageInfo_Ack proto.InternalMessageInfo + +func (m *Ack) GetStatus() Status { + if m != nil { + return m.Status + } + return Status_STATUS_UNSPECIFIED +} + +func (m *Ack) GetCohort() *CohortSpec { + if m != nil { + return m.Cohort + } + return nil +} + +// A full single-owner chunk in transit. Every frame is self-contained. +type Soc struct { + Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Owner []byte `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"` + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` + Span []byte `protobuf:"bytes,4,opt,name=span,proto3" json:"span,omitempty"` + Payload []byte `protobuf:"bytes,5,opt,name=payload,proto3" json:"payload,omitempty"` +} + +func (m *Soc) Reset() { *m = Soc{} } +func (m *Soc) String() string { return proto.CompactTextString(m) } +func (*Soc) ProtoMessage() {} +func (*Soc) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{6} +} +func (m *Soc) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Soc) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Soc.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Soc) XXX_Merge(src proto.Message) { + xxx_messageInfo_Soc.Merge(m, src) +} +func (m *Soc) XXX_Size() int { + return m.Size() +} +func (m *Soc) XXX_DiscardUnknown() { + xxx_messageInfo_Soc.DiscardUnknown(m) +} + +var xxx_messageInfo_Soc proto.InternalMessageInfo + +func (m *Soc) GetId() []byte { + if m != nil { + return m.Id + } + return nil +} + +func (m *Soc) GetOwner() []byte { + if m != nil { + return m.Owner + } + return nil +} + +func (m *Soc) GetSignature() []byte { + if m != nil { + return m.Signature + } + return nil +} + +func (m *Soc) GetSpan() []byte { + if m != nil { + return m.Span + } + return nil +} + +func (m *Soc) GetPayload() []byte { + if m != nil { + return m.Payload + } + return nil +} + +// Publisher -> broker. +type Publish struct { + Soc *Soc `protobuf:"bytes,1,opt,name=soc,proto3" json:"soc,omitempty"` +} + +func (m *Publish) Reset() { *m = Publish{} } +func (m *Publish) String() string { return proto.CompactTextString(m) } +func (*Publish) ProtoMessage() {} +func (*Publish) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{7} +} +func (m *Publish) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Publish) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Publish.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Publish) XXX_Merge(src proto.Message) { + xxx_messageInfo_Publish.Merge(m, src) +} +func (m *Publish) XXX_Size() int { + return m.Size() +} +func (m *Publish) XXX_DiscardUnknown() { + xxx_messageInfo_Publish.DiscardUnknown(m) +} + +var xxx_messageInfo_Publish proto.InternalMessageInfo + +func (m *Publish) GetSoc() *Soc { + if m != nil { + return m.Soc + } + return nil +} + +// Broker -> subscriber. +type Broadcast struct { + // Types that are valid to be assigned to Frame: + // *Broadcast_Soc + Frame isBroadcast_Frame `protobuf_oneof:"frame"` +} + +func (m *Broadcast) Reset() { *m = Broadcast{} } +func (m *Broadcast) String() string { return proto.CompactTextString(m) } +func (*Broadcast) ProtoMessage() {} +func (*Broadcast) Descriptor() ([]byte, []int) { + return fileDescriptor_786299925f8760d5, []int{8} +} +func (m *Broadcast) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *Broadcast) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_Broadcast.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *Broadcast) XXX_Merge(src proto.Message) { + xxx_messageInfo_Broadcast.Merge(m, src) +} +func (m *Broadcast) XXX_Size() int { + return m.Size() +} +func (m *Broadcast) XXX_DiscardUnknown() { + xxx_messageInfo_Broadcast.DiscardUnknown(m) +} + +var xxx_messageInfo_Broadcast proto.InternalMessageInfo + +type isBroadcast_Frame interface { + isBroadcast_Frame() + MarshalTo([]byte) (int, error) + Size() int +} + +type Broadcast_Soc struct { + Soc *Soc `protobuf:"bytes,1,opt,name=soc,proto3,oneof" json:"soc,omitempty"` +} + +func (*Broadcast_Soc) isBroadcast_Frame() {} + +func (m *Broadcast) GetFrame() isBroadcast_Frame { + if m != nil { + return m.Frame + } + return nil +} + +func (m *Broadcast) GetSoc() *Soc { + if x, ok := m.GetFrame().(*Broadcast_Soc); ok { + return x.Soc + } + return nil +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*Broadcast) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*Broadcast_Soc)(nil), + } +} + +func init() { + proto.RegisterEnum("bps.TopicBinding", TopicBinding_name, TopicBinding_value) + proto.RegisterEnum("bps.PublisherRegime", PublisherRegime_name, PublisherRegime_value) + proto.RegisterEnum("bps.Status", Status_name, Status_value) + proto.RegisterType((*CohortSpec)(nil), "bps.CohortSpec") + proto.RegisterType((*Open)(nil), "bps.Open") + proto.RegisterType((*Subscribe)(nil), "bps.Subscribe") + proto.RegisterType((*Hello)(nil), "bps.Hello") + proto.RegisterType((*PublisherAuth)(nil), "bps.PublisherAuth") + proto.RegisterType((*Ack)(nil), "bps.Ack") + proto.RegisterType((*Soc)(nil), "bps.Soc") + proto.RegisterType((*Publish)(nil), "bps.Publish") + proto.RegisterType((*Broadcast)(nil), "bps.Broadcast") +} + +func init() { proto.RegisterFile("bps.proto", fileDescriptor_786299925f8760d5) } + +var fileDescriptor_786299925f8760d5 = []byte{ + // 713 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x54, 0xdb, 0x6e, 0xda, 0x4a, + 0x14, 0xc5, 0x17, 0x0c, 0xde, 0x10, 0xe2, 0xcc, 0x89, 0x22, 0x9f, 0x23, 0x0e, 0x45, 0xae, 0xd2, + 0xa2, 0x54, 0xca, 0x03, 0x6d, 0x3f, 0x80, 0x8b, 0x13, 0x9c, 0x38, 0x06, 0x8d, 0x41, 0xa9, 0xfa, + 0x42, 0x7d, 0x6b, 0xb0, 0x42, 0x3c, 0x96, 0xc7, 0x28, 0xca, 0x5f, 0xf4, 0xb3, 0xfa, 0x98, 0xc7, + 0x3e, 0x56, 0xc9, 0x5f, 0xf4, 0xa9, 0xf2, 0x60, 0x2e, 0x89, 0xda, 0xaa, 0x6f, 0xde, 0x7b, 0xaf, + 0x59, 0x6b, 0xcd, 0x62, 0x0f, 0x20, 0xbb, 0x31, 0x3d, 0x8e, 0x13, 0x92, 0x12, 0x24, 0xb8, 0x31, + 0xd5, 0x7e, 0x70, 0x00, 0x3d, 0x32, 0x23, 0x49, 0x6a, 0xc7, 0x81, 0x87, 0xf6, 0xa1, 0x98, 0x92, + 0x38, 0xf4, 0x54, 0xae, 0xc9, 0xb5, 0xaa, 0x78, 0x59, 0xa0, 0x37, 0x50, 0x72, 0xc3, 0xc8, 0x0f, + 0xa3, 0x2b, 0x95, 0x6f, 0x72, 0xad, 0x5a, 0x7b, 0xef, 0x38, 0xa3, 0x19, 0x67, 0xc3, 0xee, 0x72, + 0x80, 0x57, 0x08, 0xf4, 0x0e, 0x20, 0x5e, 0xb8, 0xf3, 0x90, 0xce, 0x82, 0x84, 0xaa, 0x02, 0xc3, + 0xef, 0x33, 0xfc, 0x68, 0xd5, 0xc6, 0xc1, 0x55, 0x78, 0x13, 0xe0, 0x2d, 0x1c, 0x52, 0xa1, 0x34, + 0x0b, 0x69, 0x4a, 0x92, 0x3b, 0x55, 0x6c, 0x72, 0xad, 0x32, 0x5e, 0x95, 0x99, 0x25, 0xc7, 0xbf, + 0x09, 0x23, 0xb5, 0xb8, 0xb4, 0xc4, 0x0a, 0x74, 0x08, 0xb5, 0xf5, 0xe9, 0xe9, 0x3c, 0xa4, 0xa9, + 0x2a, 0x35, 0x85, 0x56, 0x15, 0xef, 0xac, 0xbb, 0x66, 0x48, 0x53, 0x74, 0x00, 0x92, 0x37, 0x27, + 0x34, 0xf0, 0xd5, 0x32, 0x63, 0xcd, 0xab, 0x33, 0xb1, 0x5c, 0x52, 0xca, 0xda, 0x25, 0x88, 0xc3, + 0x38, 0x88, 0xd0, 0x6b, 0x90, 0x3c, 0x96, 0x01, 0xbb, 0x76, 0xa5, 0xbd, 0xcb, 0xec, 0x6e, 0x62, + 0xc1, 0xf9, 0x18, 0xbd, 0x02, 0xd1, 0x59, 0xa4, 0x33, 0x96, 0x42, 0xa5, 0x8d, 0x9e, 0xde, 0xaa, + 0xb3, 0x48, 0x67, 0x98, 0xcd, 0x35, 0x03, 0x64, 0x7b, 0xe1, 0x52, 0x2f, 0x09, 0xdd, 0xe0, 0x37, + 0x99, 0xfe, 0x2d, 0x55, 0x00, 0xc5, 0x41, 0x30, 0x9f, 0x13, 0xf4, 0x02, 0x44, 0x12, 0x07, 0x51, + 0x6e, 0x51, 0x66, 0x07, 0x32, 0xf7, 0x83, 0x02, 0x66, 0x03, 0x74, 0x0c, 0x32, 0x5d, 0x89, 0xe6, + 0xb4, 0x35, 0x86, 0x5a, 0x5b, 0x19, 0x14, 0xf0, 0x06, 0xd2, 0xad, 0x80, 0x3c, 0x73, 0x22, 0x9f, + 0xce, 0x9c, 0xeb, 0x40, 0x7b, 0x0f, 0x3b, 0x4f, 0xd4, 0x33, 0xd7, 0xe4, 0x36, 0x0a, 0x92, 0x95, + 0x6b, 0x56, 0xa0, 0x1a, 0xf0, 0xa1, 0xcf, 0xc8, 0xab, 0x98, 0x0f, 0x7d, 0xcd, 0x06, 0xa1, 0xe3, + 0x5d, 0xa3, 0x97, 0x20, 0xd1, 0xd4, 0x49, 0x17, 0x94, 0xa1, 0x6b, 0xed, 0xca, 0x52, 0x97, 0xb5, + 0x70, 0x3e, 0xda, 0x4a, 0x99, 0xff, 0x63, 0xca, 0xda, 0x2d, 0x08, 0x36, 0xf1, 0x72, 0x2d, 0x6e, + 0xa5, 0xb5, 0x71, 0xc4, 0x6f, 0x3b, 0xaa, 0x83, 0x4c, 0xc3, 0xab, 0xc8, 0x49, 0x17, 0x49, 0xc0, + 0xb6, 0xad, 0x8a, 0x37, 0x0d, 0x84, 0x40, 0xa4, 0xb1, 0x13, 0xb1, 0x9d, 0xaa, 0x62, 0xf6, 0x9d, + 0xad, 0x5a, 0xec, 0xdc, 0xcd, 0x89, 0xe3, 0xe7, 0x2b, 0xb5, 0x2a, 0xb5, 0x43, 0x28, 0xe5, 0x21, + 0xa0, 0xff, 0x40, 0xa0, 0xc4, 0xcb, 0xc3, 0x2e, 0x2f, 0xaf, 0x43, 0x3c, 0x9c, 0x35, 0xb5, 0x36, + 0xc8, 0xdd, 0x84, 0x38, 0xbe, 0xe7, 0xd0, 0x14, 0xd5, 0x7f, 0x09, 0x1c, 0x14, 0x18, 0xb4, 0x5b, + 0x82, 0xe2, 0xe7, 0xc4, 0xb9, 0x09, 0x8e, 0x3e, 0x41, 0x75, 0xfb, 0xb9, 0xa0, 0xff, 0xe1, 0xdf, + 0xf1, 0x70, 0x64, 0xf4, 0xa6, 0x5d, 0xc3, 0xea, 0x1b, 0xd6, 0xe9, 0x74, 0x62, 0xd9, 0x23, 0xbd, + 0x67, 0x9c, 0x18, 0x7a, 0x5f, 0x29, 0x20, 0x00, 0xa9, 0x63, 0xf5, 0x06, 0x43, 0xac, 0x70, 0xd9, + 0xb7, 0x3d, 0xec, 0x4d, 0x8d, 0xbe, 0xc2, 0x23, 0x19, 0x8a, 0xc3, 0x4b, 0x4b, 0xc7, 0x8a, 0x80, + 0x6a, 0x00, 0x27, 0xba, 0xde, 0x9f, 0x32, 0x1a, 0x45, 0x3c, 0x4a, 0x60, 0xf7, 0xd9, 0x03, 0x43, + 0x4d, 0xa8, 0x8f, 0x26, 0x5d, 0xd3, 0xb0, 0x07, 0x3a, 0x9e, 0x62, 0xfd, 0xd4, 0xb8, 0xd0, 0x9f, + 0xe9, 0xfc, 0x03, 0xbb, 0xfa, 0x87, 0x91, 0x69, 0xf4, 0x8c, 0xf1, 0xd4, 0x36, 0xac, 0x53, 0x53, + 0x57, 0x38, 0xb4, 0x07, 0x3b, 0xeb, 0xa6, 0x69, 0xd8, 0x63, 0x85, 0x47, 0x55, 0x28, 0x1b, 0x17, + 0xcb, 0x96, 0x22, 0xa0, 0x12, 0x08, 0x1d, 0xd3, 0x54, 0xc4, 0x23, 0x1b, 0xa4, 0xe5, 0x8f, 0x8c, + 0x0e, 0x00, 0xd9, 0xe3, 0xce, 0x78, 0x62, 0x3f, 0x13, 0x90, 0x80, 0x1f, 0x9e, 0x2b, 0x1c, 0x2a, + 0x83, 0x78, 0x32, 0x31, 0x4d, 0x85, 0xcf, 0xd8, 0x27, 0xd6, 0xb9, 0x35, 0xbc, 0xb4, 0x72, 0xeb, + 0x42, 0xc6, 0x8e, 0xf5, 0x33, 0xbd, 0x37, 0xd6, 0xfb, 0x8a, 0xd8, 0xad, 0x7f, 0x7d, 0x68, 0x70, + 0xf7, 0x0f, 0x0d, 0xee, 0xfb, 0x43, 0x83, 0xfb, 0xf2, 0xd8, 0x28, 0xdc, 0x3f, 0x36, 0x0a, 0xdf, + 0x1e, 0x1b, 0x85, 0x8f, 0x7c, 0xec, 0xba, 0x12, 0xfb, 0xf3, 0x7a, 0xfb, 0x33, 0x00, 0x00, 0xff, + 0xff, 0xf5, 0x4d, 0xcd, 0x67, 0xc9, 0x04, 0x00, 0x00, +} + +func (m *CohortSpec) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CohortSpec) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *CohortSpec) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Closed { + i-- + if m.Closed { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x40 + } + if len(m.PublisherList) > 0 { + for iNdEx := len(m.PublisherList) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.PublisherList[iNdEx]) + copy(dAtA[i:], m.PublisherList[iNdEx]) + i = encodeVarintBps(dAtA, i, uint64(len(m.PublisherList[iNdEx]))) + i-- + dAtA[i] = 0x32 + } + } + if len(m.Admin) > 0 { + i -= len(m.Admin) + copy(dAtA[i:], m.Admin) + i = encodeVarintBps(dAtA, i, uint64(len(m.Admin))) + i-- + dAtA[i] = 0x2a + } + if m.History { + i-- + if m.History { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x20 + } + if m.Publishers != 0 { + i = encodeVarintBps(dAtA, i, uint64(m.Publishers)) + i-- + dAtA[i] = 0x18 + } + if m.Binding != 0 { + i = encodeVarintBps(dAtA, i, uint64(m.Binding)) + i-- + dAtA[i] = 0x10 + } + if len(m.Topic) > 0 { + i -= len(m.Topic) + copy(dAtA[i:], m.Topic) + i = encodeVarintBps(dAtA, i, uint64(len(m.Topic))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Open) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Open) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Open) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Auth != nil { + { + size, err := m.Auth.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + } + if m.Cohort != nil { + { + size, err := m.Cohort.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Subscribe) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Subscribe) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Subscribe) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Auth != nil { + { + size, err := m.Auth.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + } + if len(m.Topic) > 0 { + i -= len(m.Topic) + copy(dAtA[i:], m.Topic) + i = encodeVarintBps(dAtA, i, uint64(len(m.Topic))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Hello) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Hello) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Hello) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Handshake != nil { + { + size := m.Handshake.Size() + i -= size + if _, err := m.Handshake.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + } + } + return len(dAtA) - i, nil +} + +func (m *Hello_Open) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Hello_Open) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + if m.Open != nil { + { + size, err := m.Open.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} +func (m *Hello_Subscribe) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Hello_Subscribe) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + if m.Subscribe != nil { + { + size, err := m.Subscribe.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + } + return len(dAtA) - i, nil +} +func (m *PublisherAuth) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *PublisherAuth) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *PublisherAuth) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarintBps(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0x12 + } + if len(m.Owner) > 0 { + i -= len(m.Owner) + copy(dAtA[i:], m.Owner) + i = encodeVarintBps(dAtA, i, uint64(len(m.Owner))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Ack) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Ack) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Ack) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Cohort != nil { + { + size, err := m.Cohort.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + } + if m.Status != 0 { + i = encodeVarintBps(dAtA, i, uint64(m.Status)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *Soc) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Soc) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Soc) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarintBps(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x2a + } + if len(m.Span) > 0 { + i -= len(m.Span) + copy(dAtA[i:], m.Span) + i = encodeVarintBps(dAtA, i, uint64(len(m.Span))) + i-- + dAtA[i] = 0x22 + } + if len(m.Signature) > 0 { + i -= len(m.Signature) + copy(dAtA[i:], m.Signature) + i = encodeVarintBps(dAtA, i, uint64(len(m.Signature))) + i-- + dAtA[i] = 0x1a + } + if len(m.Owner) > 0 { + i -= len(m.Owner) + copy(dAtA[i:], m.Owner) + i = encodeVarintBps(dAtA, i, uint64(len(m.Owner))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarintBps(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Publish) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Publish) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Publish) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Soc != nil { + { + size, err := m.Soc.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Broadcast) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Broadcast) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Broadcast) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Frame != nil { + { + size := m.Frame.Size() + i -= size + if _, err := m.Frame.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + } + } + return len(dAtA) - i, nil +} + +func (m *Broadcast_Soc) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *Broadcast_Soc) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + if m.Soc != nil { + { + size, err := m.Soc.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintBps(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} +func encodeVarintBps(dAtA []byte, offset int, v uint64) int { + offset -= sovBps(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *CohortSpec) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Topic) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + if m.Binding != 0 { + n += 1 + sovBps(uint64(m.Binding)) + } + if m.Publishers != 0 { + n += 1 + sovBps(uint64(m.Publishers)) + } + if m.History { + n += 2 + } + l = len(m.Admin) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + if len(m.PublisherList) > 0 { + for _, b := range m.PublisherList { + l = len(b) + n += 1 + l + sovBps(uint64(l)) + } + } + if m.Closed { + n += 2 + } + return n +} + +func (m *Open) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Cohort != nil { + l = m.Cohort.Size() + n += 1 + l + sovBps(uint64(l)) + } + if m.Auth != nil { + l = m.Auth.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Subscribe) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Topic) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + if m.Auth != nil { + l = m.Auth.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Hello) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Handshake != nil { + n += m.Handshake.Size() + } + return n +} + +func (m *Hello_Open) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Open != nil { + l = m.Open.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} +func (m *Hello_Subscribe) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Subscribe != nil { + l = m.Subscribe.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} +func (m *PublisherAuth) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Owner) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + l = len(m.Id) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Ack) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Status != 0 { + n += 1 + sovBps(uint64(m.Status)) + } + if m.Cohort != nil { + l = m.Cohort.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Soc) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + l = len(m.Owner) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + l = len(m.Signature) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + l = len(m.Span) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Publish) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Soc != nil { + l = m.Soc.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func (m *Broadcast) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Frame != nil { + n += m.Frame.Size() + } + return n +} + +func (m *Broadcast_Soc) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Soc != nil { + l = m.Soc.Size() + n += 1 + l + sovBps(uint64(l)) + } + return n +} + +func sovBps(x uint64) (n int) { + return (math_bits.Len64(x|1) + 6) / 7 +} +func sozBps(x uint64) (n int) { + return sovBps(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *CohortSpec) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CohortSpec: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CohortSpec: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Topic", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Topic = append(m.Topic[:0], dAtA[iNdEx:postIndex]...) + if m.Topic == nil { + m.Topic = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Binding", wireType) + } + m.Binding = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Binding |= TopicBinding(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Publishers", wireType) + } + m.Publishers = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Publishers |= PublisherRegime(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field History", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.History = bool(v != 0) + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Admin", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Admin = append(m.Admin[:0], dAtA[iNdEx:postIndex]...) + if m.Admin == nil { + m.Admin = []byte{} + } + iNdEx = postIndex + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field PublisherList", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.PublisherList = append(m.PublisherList, make([]byte, postIndex-iNdEx)) + copy(m.PublisherList[len(m.PublisherList)-1], dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 8: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Closed", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Closed = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Open) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Open: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Open: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Cohort", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Cohort == nil { + m.Cohort = &CohortSpec{} + } + if err := m.Cohort.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Auth", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Auth == nil { + m.Auth = &PublisherAuth{} + } + if err := m.Auth.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Subscribe) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Subscribe: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Subscribe: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Topic", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Topic = append(m.Topic[:0], dAtA[iNdEx:postIndex]...) + if m.Topic == nil { + m.Topic = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Auth", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Auth == nil { + m.Auth = &PublisherAuth{} + } + if err := m.Auth.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Hello) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Hello: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Hello: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Open", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + v := &Open{} + if err := v.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + m.Handshake = &Hello_Open{v} + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Subscribe", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + v := &Subscribe{} + if err := v.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + m.Handshake = &Hello_Subscribe{v} + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *PublisherAuth) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: PublisherAuth: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: PublisherAuth: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Owner", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Owner = append(m.Owner[:0], dAtA[iNdEx:postIndex]...) + if m.Owner == nil { + m.Owner = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = append(m.Id[:0], dAtA[iNdEx:postIndex]...) + if m.Id == nil { + m.Id = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Ack) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Ack: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Ack: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType) + } + m.Status = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Status |= Status(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Cohort", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Cohort == nil { + m.Cohort = &CohortSpec{} + } + if err := m.Cohort.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Soc) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Soc: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Soc: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = append(m.Id[:0], dAtA[iNdEx:postIndex]...) + if m.Id == nil { + m.Id = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Owner", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Owner = append(m.Owner[:0], dAtA[iNdEx:postIndex]...) + if m.Owner == nil { + m.Owner = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Signature", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Signature = append(m.Signature[:0], dAtA[iNdEx:postIndex]...) + if m.Signature == nil { + m.Signature = []byte{} + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Span", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Span = append(m.Span[:0], dAtA[iNdEx:postIndex]...) + if m.Span == nil { + m.Span = []byte{} + } + iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Publish) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Publish: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Publish: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Soc", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Soc == nil { + m.Soc = &Soc{} + } + if err := m.Soc.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Broadcast) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Broadcast: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Broadcast: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Soc", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowBps + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthBps + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthBps + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + v := &Soc{} + if err := v.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + m.Frame = &Broadcast_Soc{v} + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipBps(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) < 0 { + return ErrInvalidLengthBps + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func skipBps(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowBps + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowBps + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowBps + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLengthBps + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroupBps + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLengthBps + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLengthBps = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflowBps = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroupBps = fmt.Errorf("proto: unexpected end of group") +) diff --git a/pkg/bps/pb/bps.proto b/pkg/bps/pb/bps.proto new file mode 100644 index 00000000000..8364aea5778 --- /dev/null +++ b/pkg/bps/pb/bps.proto @@ -0,0 +1,113 @@ +// 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. + +// Broadcast Pub/Sub (BPS) — protocol messages and types. +// Spec: SWIP-60, https://github.com/ethersphere/SWIPs/pull/104 +// +// Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is +// deliberately NOT a legitimate wire value. It exists so that an unset field +// is detectable and no implementation can silently rely on a default. +// Receivers MUST reject messages carrying it. +// +// The singlehop (depth = 1) subset is concrete; multihop control-plane +// messages are reserved. + +syntax = "proto3"; + +package bps; + +option go_package = "pb"; + +// What the topic binds to (see SWIP-60: binding semantics). +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire + ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC + SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= PO_MIN + OWNER = 3; // topic = SOC owner; any id under the same PO constraint (MIC) + FEED_TOPIC = 4; // id = keccak256(topic || index); graffiti MIC / feed streams +} + +// Who may author. +enum PublisherRegime { + PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire + EXPLICIT_SINGLE = 1; // opener is the sole publisher + EXPLICIT_LIST = 2; // set fixed at genesis: admin + publisher_list + IMPLICIT = 3; // authorship implied by the topic binding + ALL = 4; // every peer publishes +} + +// Fixed by the cohort's opener; immutable for the cohort's lifetime. +message CohortSpec { + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + PublisherRegime publishers = 3; + bool history = 4; + bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* + repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin + reserved 7; // was po_min — now protocol constant + bool closed = 8; // no audience +} + +// Opener -> broker: the one peer that fixes the cohort. +message Open { + CohortSpec cohort = 1; + PublisherAuth auth = 2; // present iff the opener publishes +} + +// Joiner -> broker: names the topic — nothing more. +message Subscribe { + bytes topic = 1; // 32 bytes + PublisherAuth auth = 2; // present iff publisher +} + +// Peer -> broker: the first frame on a fresh stream. Not in SWIP-60 as +// published; added because Open and Subscribe are otherwise indistinguishable +// on the wire. See the design doc. +message Hello { + oneof handshake { + Open open = 1; + Subscribe subscribe = 2; + } +} + +message PublisherAuth { + bytes owner = 1; // 20-byte eth address of the SOC owner key + bytes id = 2; // 32-byte SOC id, when the binding fixes it +} + +// Broker -> peer, answering Open or Subscribe. +message Ack { + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire + OK = 1; + FULL = 2; // broker at its per-topic capacity + UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve + REJECTED = 4; // publisher not on the list, invalid auth, etc. +} + +// A full single-owner chunk in transit. Every frame is self-contained. +message Soc { + bytes id = 1; // 32 bytes + bytes owner = 2; // 20 bytes + bytes signature = 3; // 65 bytes + bytes span = 4; // 8 bytes LE + bytes payload = 5; // wrapped-CAC data, <= 4096 bytes +} + +// Publisher -> broker. +message Publish { + Soc soc = 1; +} + +// Broker -> subscriber. +message Broadcast { + oneof frame { + Soc soc = 1; + // 2–15 reserved: multihop control plane. + } +} diff --git a/pkg/bps/pb/bps_test.go b/pkg/bps/pb/bps_test.go new file mode 100644 index 00000000000..145c8c146ab --- /dev/null +++ b/pkg/bps/pb/bps_test.go @@ -0,0 +1,73 @@ +// 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 pb_test + +import ( + "bytes" + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/gogo/protobuf/proto" +) + +func TestHelloDisambiguates(t *testing.T) { + t.Parallel() + + topic := bytes.Repeat([]byte{0x2a}, 32) + + open := &pb.Hello{Handshake: &pb.Hello_Open{Open: &pb.Open{ + Cohort: &pb.CohortSpec{ + Topic: topic, + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + }, + }}} + sub := &pb.Hello{Handshake: &pb.Hello_Subscribe{Subscribe: &pb.Subscribe{ + Topic: topic, + }}} + + for _, tc := range []struct { + name string + msg *pb.Hello + open bool + }{ + {name: "open", msg: open, open: true}, + {name: "subscribe", msg: sub, open: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + b, err := proto.Marshal(tc.msg) + if err != nil { + t.Fatal(err) + } + var got pb.Hello + if err := proto.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if tc.open { + if got.GetOpen() == nil { + t.Fatal("expected Open handshake") + } + if got.GetSubscribe() != nil { + t.Fatal("unexpected Subscribe handshake") + } + if !bytes.Equal(got.GetOpen().GetCohort().GetTopic(), topic) { + t.Fatal("topic mismatch") + } + return + } + if got.GetSubscribe() == nil { + t.Fatal("expected Subscribe handshake") + } + if got.GetOpen() != nil { + t.Fatal("unexpected Open handshake") + } + if !bytes.Equal(got.GetSubscribe().GetTopic(), topic) { + t.Fatal("topic mismatch") + } + }) + } +} diff --git a/pkg/bps/pb/doc.go b/pkg/bps/pb/doc.go new file mode 100644 index 00000000000..5653c216423 --- /dev/null +++ b/pkg/bps/pb/doc.go @@ -0,0 +1,7 @@ +// 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. + +//go:generate sh -c "protoc -I . -I \"$(go list -f '{{ .Dir }}' -m github.com/gogo/protobuf)/protobuf\" --gogofaster_out=. bps.proto" + +package pb diff --git a/pkg/bps/publisher.go b/pkg/bps/publisher.go new file mode 100644 index 00000000000..032cf400dbc --- /dev/null +++ b/pkg/bps/publisher.go @@ -0,0 +1,49 @@ +// 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 bps + +import ( + "bytes" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" +) + +// ErrNotPublisher is returned when an owner is outside the cohort's genesis +// publisher set. +var ErrNotPublisher = errors.New("bps: not a legitimate publisher") + +// authorizePublisher checks owner against the cohort's publisher regime. +// +// Under explicit regimes the genesis set decides, and it is fixed at Open: +// dynamic grants and revocations are deferred by SWIP-60 to a later revision. +// +// At handshake time the owner is only declared, never proved — a PublisherAuth +// is not a credential. This check is an early refusal. The binding gate is the +// same call at Publish time, against the owner recovered from the message's +// signature, which is what actually authenticates a publisher. +func authorizePublisher(spec *pb.CohortSpec, owner []byte) error { + if len(owner) == 0 { + return fmt.Errorf("no owner: %w", ErrNotPublisher) + } + + switch spec.GetPublishers() { + case pb.PublisherRegime_EXPLICIT_SINGLE: + if !bytes.Equal(owner, spec.GetAdmin()) { + return fmt.Errorf("owner %x is not the admin: %w", owner, ErrNotPublisher) + } + return nil + case pb.PublisherRegime_EXPLICIT_LIST: + for _, p := range Publishers(spec) { + if bytes.Equal(owner, p) { + return nil + } + } + return fmt.Errorf("owner %x is not on the publisher list: %w", owner, ErrNotPublisher) + default: + return fmt.Errorf("regime %s: %w", spec.GetPublishers(), ErrUnsupportedRegime) + } +} diff --git a/pkg/bps/session.go b/pkg/bps/session.go new file mode 100644 index 00000000000..1ba2335b018 --- /dev/null +++ b/pkg/bps/session.go @@ -0,0 +1,308 @@ +// 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 bps + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/ethersphere/bee/v2/pkg/bps/pb" + "github.com/ethersphere/bee/v2/pkg/p2p" + "github.com/ethersphere/bee/v2/pkg/p2p/protobuf" + "github.com/ethersphere/bee/v2/pkg/soc" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// ErrRefused is matched by every RefusalError. +var ErrRefused = errors.New("bps: broker refused the handshake") + +// RefusalError is returned when a broker answers a handshake with a non-OK +// status. The status distinguishes a full broker from an unknown topic or a +// rejection, which callers act on differently. +type RefusalError struct { + Status pb.Status +} + +func (e *RefusalError) Error() string { + return fmt.Sprintf("bps: broker refused the handshake: %s", e.Status) +} + +// Is reports whether target is ErrRefused, so callers can match the class +// without naming the status. +func (e *RefusalError) Is(target error) bool { + return target == ErrRefused +} + +// Session is one peer's participation in one cohort: a long-lived stream to +// the broker, plus fan-out to local consumers. +type Session struct { + svc *Service + topic swarm.Address + spec *pb.CohortSpec + stream p2p.Stream + w protobuf.Writer + publisher bool + + messages chan *soc.SOC + + // writeMu serialises writes to w. The protobuf writer wraps a varint + // framing writer with mutable per-write scratch state, so two concurrent + // Publish calls would interleave bytes on the wire and desynchronise the + // broker's framing permanently. Publish is part of the exported Publisher + // surface and its intended consumer is a WebSocket bridge, where + // concurrent writes are the normal shape, so the lock lives here rather + // than being every caller's problem. + writeMu sync.Mutex + + closeOnce sync.Once + quit chan struct{} + // readDone is closed when the read loop this session started actually + // returns. Close waits on it, so the goroutine's lifetime is owned by + // the Session that started it rather than by Service. + readDone chan struct{} +} + +// Open fixes a new cohort at peer, or joins an existing one with an identical +// spec — SWIP-60's Open is idempotent, so a client need not know whether it is +// first. A non-nil auth makes the session a publisher. +func (s *Service) Open(ctx context.Context, peer swarm.Address, spec *pb.CohortSpec, auth *pb.PublisherAuth) (*Session, error) { + if err := ValidateSpec(spec); err != nil { + return nil, err + } + return s.handshake(ctx, peer, swarm.NewAddress(spec.GetTopic()), spec, &pb.Hello{ + Handshake: &pb.Hello_Open{Open: &pb.Open{Cohort: spec, Auth: auth}}, + }, auth != nil) +} + +// Subscribe joins an existing cohort at peer, learning its spec from the Ack +// echo. A non-nil auth makes the session a publisher. +func (s *Service) Subscribe(ctx context.Context, peer swarm.Address, topic swarm.Address, auth *pb.PublisherAuth) (*Session, error) { + return s.handshake(ctx, peer, topic, nil, &pb.Hello{ + Handshake: &pb.Hello_Subscribe{Subscribe: &pb.Subscribe{Topic: topic.Bytes(), Auth: auth}}, + }, auth != nil) +} + +// handshake performs the client side of the Hello/Ack exchange. want is the +// spec the caller asked for, or nil when the caller has nothing to compare +// against — see the spec check below. +func (s *Service) handshake(ctx context.Context, peer swarm.Address, topic swarm.Address, want *pb.CohortSpec, hello *pb.Hello, publisher bool) (ss *Session, err error) { + select { + case <-s.quit: + return nil, ErrShutdown + default: + } + + // Bound the exchange the same way the broker bounds its own side: with a + // context.Background() caller, a broker that accepts the stream and never + // answers would otherwise park the caller forever. + ctx, cancel := context.WithTimeout(ctx, HandshakeTimeout) + defer cancel() + + stream, err := s.streamer.NewStream(ctx, peer, nil, ProtocolName, ProtocolVersion, StreamName) + if err != nil { + return nil, fmt.Errorf("new stream: %w", err) + } + defer func() { + if err != nil { + _ = stream.Reset() + } + }() + + w, r := protobuf.NewWriterAndReader(stream) + if err := w.WriteMsgWithContext(ctx, hello); err != nil { + return nil, fmt.Errorf("write hello: %w", err) + } + + var ack pb.Ack + if err := r.ReadMsgWithContext(ctx, &ack); err != nil { + return nil, fmt.Errorf("read ack: %w", err) + } + if ack.GetStatus() != pb.Status_OK { + return nil, &RefusalError{Status: ack.GetStatus()} + } + // The echoed spec is what every inbound message is verified against, so a + // broker that echoes nonsense is refused here rather than trusted later. + if err := ValidateSpec(ack.GetCohort()); err != nil { + return nil, fmt.Errorf("echoed cohort spec: %w", err) + } + if !topic.Equal(swarm.NewAddress(ack.GetCohort().GetTopic())) { + return nil, fmt.Errorf("echoed topic %x: %w", ack.GetCohort().GetTopic(), ErrSpecMismatch) + } + // An Open knows exactly which cohort it asked for, so the echo must match + // it field for field: adopting the broker's version instead would let a + // broker substitute the publisher set, the admin, or the closed flag, and + // that substituted spec is the sole input to verify() for every later + // message. Subscribe cannot make this check — it learns the spec from the + // echo and has nothing to compare against — so a subscriber is only ever + // as trustworthy as its knowledge of the spec. That caveat now applies + // across every supported binding: under an explicit publisher regime the + // topic no longer pins the owner (ANCHOR included — see anchorBinding), + // so a hostile broker can echo a substituted admin or publisher list and + // have it accepted by a spec-less Subscribe. Subscribe will need the spec + // supplied out of band by the invite rather than learned from the broker. + if want != nil && !SpecEqual(want, ack.GetCohort()) { + return nil, fmt.Errorf("broker echoed a different spec: %w", ErrSpecMismatch) + } + + ss = &Session{ + svc: s, + topic: topic, + spec: ack.GetCohort(), + stream: stream, + w: w, + publisher: publisher, + messages: make(chan *soc.SOC, OutboundQueueSize), + quit: make(chan struct{}), + readDone: make(chan struct{}), + } + + s.sessionsMu.Lock() + s.sessions[ss] = struct{}{} + s.sessionsMu.Unlock() + + go func() { + defer close(ss.readDone) + ss.read(r) + }() + + return ss, nil +} + +// Topic returns the cohort's topic. +func (ss *Session) Topic() swarm.Address { return ss.topic } + +// Spec returns the cohort spec echoed by the broker. Every inbound message is +// verified against it end to end. The returned spec is owned by the session +// and must be treated as read-only: mutating it changes the rules verify +// enforces on every subsequent message. +func (ss *Session) Spec() *pb.CohortSpec { return ss.spec } + +// Messages returns the channel of verified inbound messages. It is closed when +// the session ends. +func (ss *Session) Messages() <-chan *soc.SOC { return ss.messages } + +// Publish sends a single-owner chunk to the broker. It fails for a read-only +// session, and for a chunk that would not survive the broker's own checks — +// there is no point spending a round trip on a message the broker will drop. +// +// Publish is safe for concurrent use: calls from multiple goroutines are +// serialised on the session's stream, so frames never interleave. +func (ss *Session) Publish(ctx context.Context, s *soc.SOC) error { + if !ss.publisher { + return fmt.Errorf("read-only session: %w", ErrNotPublisher) + } + select { + case <-ss.quit: + return ErrShutdown + default: + } + + if err := ss.verify(s); err != nil { + return err + } + + m, err := SocToProto(s) + if err != nil { + return err + } + ss.writeMu.Lock() + err = ss.w.WriteMsgWithContext(ctx, &pb.Publish{Soc: m}) + ss.writeMu.Unlock() + if err != nil { + return fmt.Errorf("write publish: %w", err) + } + ss.svc.metrics.Published.Inc() + + return nil +} + +// verify checks a message against the cohort spec: it must qualify under the +// topic binding, and its owner must be a legitimate publisher. This is the +// end-to-end check SWIP-60 requires of every subscriber — the broker can +// withhold, never forge. +func (ss *Session) verify(s *soc.SOC) error { + b, err := bindingFor(ss.spec.GetBinding()) + if err != nil { + return err + } + if err := b.qualifies(ss.spec, s); err != nil { + return err + } + return authorizePublisher(ss.spec, s.OwnerAddress()) +} + +func (ss *Session) read(r protobuf.Reader) { + defer close(ss.messages) + + for { + var bc pb.Broadcast + if err := r.ReadMsg(&bc); err != nil { + select { + case <-ss.quit: + default: + ss.svc.logger.Debug("session read", "topic", ss.topic, "error", err) + } + return + } + + m := bc.GetSoc() + if m == nil { + // Reserved multihop control frames: unknown to a singlehop peer, + // ignored rather than fatal, so bps-multihop needs no version bump. + continue + } + + s, err := SocFromProto(m) + if err != nil { + ss.svc.metrics.Dropped.WithLabelValues("malformed").Inc() + ss.svc.logger.Debug("session: malformed message", "topic", ss.topic, "error", err) + continue + } + if err := ss.verify(s); err != nil { + ss.svc.metrics.Dropped.WithLabelValues("unverified").Inc() + ss.svc.logger.Debug("session: message failed verification", "topic", ss.topic, "error", err) + continue + } + + select { + case ss.messages <- s: + case <-ss.quit: + return + } + } +} + +// Close ends the session and tears down its stream. It does not return until +// the session's own read loop has actually returned, so the goroutine never +// outlives Close — every caller waits on readDone, not just whichever one +// happened to run the teardown. +func (ss *Session) Close() error { + ss.closeOnce.Do(func() { + close(ss.quit) + _ = ss.stream.Reset() + + ss.svc.sessionsMu.Lock() + delete(ss.svc.sessions, ss) + ss.svc.sessionsMu.Unlock() + }) + <-ss.readDone + return nil +} + +// Publisher is the local surface of a cohort session, as downstream consumers +// (the WebSocket bridge, later) see it. +type Publisher interface { + Topic() swarm.Address + Spec() *pb.CohortSpec + // Publish sends a single-owner chunk to the broker. Implementations must + // be safe for concurrent use. + Publish(ctx context.Context, s *soc.SOC) error + Messages() <-chan *soc.SOC + Close() error +} + +var _ Publisher = (*Session)(nil) diff --git a/pkg/bps/session_test.go b/pkg/bps/session_test.go new file mode 100644 index 00000000000..58aaf53960c --- /dev/null +++ b/pkg/bps/session_test.go @@ -0,0 +1,233 @@ +// 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 bps_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/bps/pb" + bpstesting "github.com/ethersphere/bee/v2/pkg/bps/testing" + "github.com/ethersphere/bee/v2/pkg/log" + "github.com/ethersphere/bee/v2/pkg/p2p/streamtest" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// newClient returns a client service whose streamer routes to broker. +func newClient(t *testing.T, broker *bps.Service, brokerAddr swarm.Address) *bps.Service { + t.Helper() + + recorder := streamtest.New( + streamtest.WithProtocols(broker.Protocol()), + streamtest.WithBaseAddr(brokerAddr), + ) + client := bps.New(recorder, log.Noop, bps.Options{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatal(err) + } + }) + return client +} + +func TestSessionOpenAndSubscribe(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec := validSpec() + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + if !pub.Topic().Equal(swarm.NewAddress(spec.Topic)) { + t.Fatalf("topic: got %s", pub.Topic()) + } + if !bps.SpecEqual(pub.Spec(), spec) { + t.Fatal("session did not retain the echoed spec") + } + + sub, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if !bps.SpecEqual(sub.Spec(), spec) { + t.Fatal("subscriber did not learn the spec from the Ack echo") + } +} + +func TestSessionRefusal(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec := validSpec() + _, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil) + if !errors.Is(err, bps.ErrRefused) { + t.Fatalf("got %v want %v", err, bps.ErrRefused) + } + var refusal *bps.RefusalError + if !errors.As(err, &refusal) || refusal.Status != pb.Status_UNKNOWN_TOPIC { + t.Fatalf("got %v want UNKNOWN_TOPIC", err) + } +} + +func TestSessionPublishRequiresPublisherRole(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + signer, owner := bpstesting.NewSigner(t) + s := bpstesting.AnchorSOC(t, signer, topic(0x31), []byte("payload")) + anchor, err := s.Address() + if err != nil { + t.Fatal(err) + } + spec := &pb.CohortSpec{ + Topic: anchor.Bytes(), + Binding: pb.TopicBinding_ANCHOR, + Publishers: pb.PublisherRegime_EXPLICIT_SINGLE, + Admin: owner, + } + + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: owner}) + if err != nil { + t.Fatal(err) + } + defer pub.Close() + + sub, err := client.Subscribe(ctx, brokerAddr, anchor, nil) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + + if err := sub.Publish(ctx, s); !errors.Is(err, bps.ErrNotPublisher) { + t.Fatalf("got %v want %v", err, bps.ErrNotPublisher) + } +} + +// TestServiceCloseTearsDownLiveSessions ensures that closing a Service with +// an open, unclosed session does not leak: Close must tear down live +// sessions itself, and each session's Close waits for its own read-loop +// goroutine to actually return, all well within Close's 5-second budget. +// This test constructs its own client service, rather than using newClient, +// because it deliberately calls Close itself instead of relying on +// t.Cleanup. +func TestServiceCloseTearsDownLiveSessions(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + + recorder := streamtest.New( + streamtest.WithProtocols(broker.Protocol()), + streamtest.WithBaseAddr(brokerAddr), + ) + client := bps.New(recorder, log.Noop, bps.Options{}) + + spec := validSpec() + _, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + // Deliberately not closed: Close on the Service must tear it down. + + done := make(chan error, 1) + go func() { + done <- client.Close() + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("got %v want nil", err) + } + case <-time.After(4 * time.Second): + t.Fatal("Service.Close did not return within 4 seconds") + } +} + +// TestServiceCloseIsIdempotent pins that calling Close twice is safe and +// returns nil both times, without redoing teardown. Close closes an internal +// channel exactly once, guarded by a sync.Once rather than a bare +// select-on-quit/default, precisely so a second call cannot race the first +// into closing an already-closed channel. +// +// The two calls are concurrent deliberately: sequential calls pass even +// against the racy select-on-quit/default version, since by the time the +// second call runs the channel is visibly closed. Only overlapping calls can +// both take the branch that closes it. +func TestServiceCloseIsIdempotent(t *testing.T) { + t.Parallel() + + broker, _, _ := newBroker(t, bps.Options{}) + + var wg sync.WaitGroup + errs := make([]error, 2) + start := make(chan struct{}) + for i := range errs { + wg.Add(1) + go func(i int) { + defer wg.Done() + + <-start + errs[i] = broker.Close() + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("close %d: got %v want nil", i, err) + } + } +} + +// TestServiceAfterClose pins that a closed service refuses new work rather +// than handing out sessions it will never serve, and that a session torn down +// by Close refuses to publish. +func TestServiceAfterClose(t *testing.T) { + t.Parallel() + + ctx := context.Background() + broker, _, brokerAddr := newBroker(t, bps.Options{}) + client := newClient(t, broker, brokerAddr) + + spec, _, msg := anchorCohort(t, topic(0xc0), []byte("after close")) + pub, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}) + if err != nil { + t.Fatal(err) + } + + if err := client.Close(); err != nil { + t.Fatal(err) + } + + if _, err := client.Open(ctx, brokerAddr, spec, &pb.PublisherAuth{Owner: spec.Admin}); !errors.Is(err, bps.ErrShutdown) { + t.Fatalf("open: got %v want %v", err, bps.ErrShutdown) + } + if _, err := client.Subscribe(ctx, brokerAddr, swarm.NewAddress(spec.Topic), nil); !errors.Is(err, bps.ErrShutdown) { + t.Fatalf("subscribe: got %v want %v", err, bps.ErrShutdown) + } + if err := pub.Publish(ctx, msg); !errors.Is(err, bps.ErrShutdown) { + t.Fatalf("publish: got %v want %v", err, bps.ErrShutdown) + } +} diff --git a/pkg/bps/testing/bps.go b/pkg/bps/testing/bps.go new file mode 100644 index 00000000000..3a588ffe3be --- /dev/null +++ b/pkg/bps/testing/bps.go @@ -0,0 +1,62 @@ +// 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 testing provides fixture builders for BPS protocol tests. +package testing + +import ( + "testing" + + "github.com/ethersphere/bee/v2/pkg/bps" + "github.com/ethersphere/bee/v2/pkg/cac" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/soc" +) + +// NewSigner returns a random signer and the 20-byte ethereum address of its key. +func NewSigner(t *testing.T) (crypto.Signer, []byte) { + t.Helper() + + key, err := crypto.GenerateSecp256k1Key() + if err != nil { + t.Fatal(err) + } + signer := crypto.NewDefaultSigner(key) + owner, err := signer.EthereumAddress() + if err != nil { + t.Fatal(err) + } + return signer, owner.Bytes() +} + +// AnchorSOC builds a signed single-owner chunk wrapping payload under id. +func AnchorSOC(t *testing.T, signer crypto.Signer, id, payload []byte) *soc.SOC { + t.Helper() + + ch, err := cac.New(payload) + if err != nil { + t.Fatal(err) + } + signed, err := soc.New(id, ch).Sign(signer) + if err != nil { + t.Fatal(err) + } + s, err := soc.FromChunk(signed) + if err != nil { + t.Fatal(err) + } + return s +} + +// FeedSOC builds a signed single-owner chunk wrapping payload under the +// feed-topic id derived from topic and index. +func FeedSOC(t *testing.T, signer crypto.Signer, topic []byte, index uint64, payload []byte) *soc.SOC { + t.Helper() + + id, err := bps.FeedID(topic, index) + if err != nil { + t.Fatal(err) + } + return AnchorSOC(t, signer, id, payload) +} diff --git a/pkg/node/node.go b/pkg/node/node.go index 9ef7704b9dd..c75293e137e 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -31,6 +31,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/bps" "github.com/ethersphere/bee/v2/pkg/config" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds/factory" @@ -113,6 +114,7 @@ type Bee struct { pullSyncCloser io.Closer pssCloser io.Closer gsocCloser io.Closer + bpsCloser io.Closer transactionMonitorCloser io.Closer transactionCloser io.Closer listenerCloser io.Closer @@ -149,6 +151,7 @@ type Options struct { BlockSyncInterval uint64 BootnodeMode bool Bootnodes []string + BpsCapacity int CacheCapacity uint64 AutoTLSCAEndpoint string ChainID int64 @@ -1103,6 +1106,15 @@ func NewBee( b.pssCloser = pssService b.gsocCloser = gsocService + bpsService := bps.New(p2ps, logger, bps.Options{Capacity: o.BpsCapacity}) + b.bpsCloser = bpsService + if o.FullNodeMode && !o.BootnodeMode { + if err = p2ps.AddProtocol(bpsService.Protocol()); err != nil { + return nil, fmt.Errorf("bps service: %w", err) + } + } + bpsBridge := bps.NewBridge(bpsService, p2ps, logger) + validStamp := postage.ValidStamp(batchStore) // metrics exposed on the status protocol @@ -1378,6 +1390,7 @@ func NewBee( Resolver: multiResolver, Pss: pssService, Gsoc: gsocService, + Bps: bpsBridge, FeedFactory: feedFactory, Post: post, AccessControl: accesscontrol, @@ -1424,6 +1437,7 @@ func NewBee( if pssServiceMetrics, ok := pssService.(metrics.Collector); ok { apiService.MustRegisterMetrics(pssServiceMetrics.Metrics()...) } + apiService.MustRegisterMetrics(bpsService.Metrics()...) if swapBackendMetrics, ok := chainBackend.(metrics.Collector); ok { apiService.MustRegisterMetrics(swapBackendMetrics.Metrics()...) }