From 6126b83f4fba00c23326e3871e66f9249a0b1141 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 1 Jul 2026 15:47:21 +0300 Subject: [PATCH 1/5] feat: headless shadow Client driven by injected nodes + pluggable relay Add a headless (shadow) Client that runs whatsmeow's full protocol handling (binary (de)coding, stanza dispatch, node handlers, event emission) without a live socket, for embedding the protocol layer inside another system. - ShadowRelay: pluggable backend a headless client delegates real-session work to (outbound SendNode + a session/keying oracle: DecryptDM, EncryptForDevice, FetchPreKeys, GetUserDevices, GetUserInfo, ResolveLID, GetPrivacyToken). Defined purely in whatsmeow-ecosystem types. - NewShadowClient: builds a real *Client with nodeHandlers populated like NewClient, no socket, Connect guarded (ErrShadowClientNoConnect), Store = seeded snapshot; send path routes marshaled nodes through relay.SendNode; Signal/keying entry points consult the relay; LID/privacy-token store reads fall back to the relay behind the seeded snapshot. - InjectNode: replays the receive-loop dispatch for an already-decoded node (RawNodeHandler hook, Signal-disabled handoff, IQ correlation, tag handlers) synchronously, since a shadow starts no handler-queue loop. - sendNodeAndGetData fails closed when there is neither socket nor relay, so a write can never silently escape or nil-panic on the absent socket. Adds shadow_test.go (fork-internal). (cherry picked from commit 1cdccb8b316969175be0de5afff4811ec53cd850) --- client.go | 23 ++++ message.go | 5 + prekeys.go | 10 ++ send.go | 5 + shadow.go | 272 +++++++++++++++++++++++++++++++++++++++++++++ shadow_test.go | 295 +++++++++++++++++++++++++++++++++++++++++++++++++ user.go | 10 ++ 7 files changed, 620 insertions(+) create mode 100644 shadow.go create mode 100644 shadow_test.go diff --git a/client.go b/client.go index f809407df..2da5aa8c8 100644 --- a/client.go +++ b/client.go @@ -226,6 +226,11 @@ type Client struct { // after decoding but before standard dispatch. See [RawNodeHandler]. RawNodeHandler RawNodeHandler + // shadowRelay, if non-nil, marks this client as a headless "shadow" + // client (see [NewShadowClient]): it has no socket, Connect is guarded, + // and outbound nodes plus Signal/keying oracle ops are delegated here. + shadowRelay ShadowRelay + // DisabledFeatures controls which built-in processing paths are // skipped. See [DisabledFeatures]. DisabledFeatures DisabledFeatures @@ -585,6 +590,12 @@ func (cli *Client) connect(ctx context.Context) error { } func (cli *Client) unlockedConnect(ctx context.Context) error { + if cli.shadowRelay != nil { + // Guard: a shadow client must never open a socket. All Connect + // paths (Connect, ConnectContext, autoReconnect) funnel through + // here, so failing closed here blocks every one of them. + return ErrShadowClientNoConnect + } if cli.Store.Deleted { return store.ErrDeviceDeleted } @@ -1040,6 +1051,18 @@ func (cli *Client) sendNodeAndGetData(ctx context.Context, node waBinary.Node) ( sock := cli.socket cli.socketLock.RUnlock() if sock == nil { + // A headless (shadow) client has no socket by design; route the + // outbound node to its relay instead. Fail closed if there is + // neither a socket nor a relay so a write can never silently + // escape or nil-panic on the absent socket. + if cli.shadowRelay != nil { + payload, err := waBinary.Marshal(node) + if err != nil { + return nil, fmt.Errorf("failed to marshal node: %w", err) + } + cli.sendLog.Debugf("%s", node.XMLString()) + return payload, cli.shadowRelay.SendNode(ctx, payload) + } return nil, ErrNotConnected } diff --git a/message.go b/message.go index 19e3ba5f5..456e32199 100644 --- a/message.go +++ b/message.go @@ -602,6 +602,11 @@ func (cli *Client) bufferedDecrypt( } func (cli *Client) decryptDM(ctx context.Context, child *waBinary.Node, from types.JID, isPreKey bool, serverTS time.Time) ([]byte, *[32]byte, error) { + if cli.isShadow() { + // A headless client holds no live Signal session; delegate + // decryption to the relay oracle. + return cli.shadowRelay.DecryptDM(ctx, child, from, isPreKey) + } content, ok := child.Content.([]byte) if !ok { return nil, nil, fmt.Errorf("message content is not a byte slice") diff --git a/prekeys.go b/prekeys.go index cc2dadd57..10fb9013a 100644 --- a/prekeys.go +++ b/prekeys.go @@ -102,6 +102,16 @@ func (cli *Client) fetchPreKeysNoError(ctx context.Context, retryDevices []types if len(retryDevices) == 0 { return nil } + if cli.isShadow() { + // A headless client has no socket to run the prekey IQ; delegate to + // the relay oracle. + bundles, err := cli.shadowRelay.FetchPreKeys(ctx, retryDevices) + if err != nil { + cli.Log.Warnf("Failed to fetch prekeys for %v via relay: %v", retryDevices, err) + return nil + } + return bundles + } bundlesResp, err := cli.fetchPreKeys(ctx, retryDevices) if err != nil { cli.Log.Warnf("Failed to fetch prekeys for %v with no existing session: %v", retryDevices, err) diff --git a/send.go b/send.go index 0a01b2733..7465cf9b1 100644 --- a/send.go +++ b/send.go @@ -1438,6 +1438,11 @@ func (cli *Client) encryptMessageForDevice( extraAttrs waBinary.Attrs, existingSessions map[string]bool, ) (*waBinary.Node, bool, error) { + if cli.isShadow() { + // A headless client holds no live Signal session; delegate + // per-device encryption to the relay oracle. + return cli.shadowRelay.EncryptForDevice(ctx, plaintext, to, bundle, extraAttrs) + } builder := session.NewBuilderFromSignal(cli.Store, to.SignalAddress(), pbSerializer) if bundle != nil { cli.Log.Debugf("Processing prekey bundle for %s", to) diff --git a/shadow.go b/shadow.go new file mode 100644 index 000000000..8eabab7ae --- /dev/null +++ b/shadow.go @@ -0,0 +1,272 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package whatsmeow + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mau.fi/libsignal/keys/prekey" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" +) + +// ErrShadowClientNoConnect is returned when Connect (or any code path that +// tries to open a socket) is called on a headless "shadow" client. A shadow +// client has no socket by design: outbound traffic is routed through its +// [ShadowRelay] instead. +var ErrShadowClientNoConnect = errors.New("shadow client cannot open a socket; outbound traffic goes through the relay") + +// ErrNilNode is returned by [Client.InjectNode] when passed a nil node. +var ErrNilNode = errors.New("cannot inject nil node") + +// ShadowRelay is the pluggable backend that a headless ("shadow") [Client] +// delegates real-session work to. +// +// A shadow client runs whatsmeow's full protocol handling (binary +// (de)coding, stanza dispatch, node handlers, event emission) without a live +// socket and without owning the Signal/session state. Work that a client +// with no transport and no local session keys cannot do itself is handed off +// to the relay: +// +// - SendNode transmits an already-marshaled binary node to the real +// transport (the send path substitutes this for a socket write). +// - The remaining methods are a session/keying oracle: decryption, +// per-device encryption, prekey fetching, device/user resolution, LID +// resolution and privacy-token lookup. The headless client consults +// these where it would otherwise reach into a live Signal session or +// issue an IQ over the socket. +// +// Implementations may talk to any transport (another process, a remote +// service, an in-memory peer). The interface is defined purely in terms of +// whatsmeow-ecosystem types so the headless client stays embeddable. +type ShadowRelay interface { + // SendNode transmits an already-marshaled binary node to the real + // transport. nodeData is the wire payload produced by + // waBinary.Marshal. It must not be retained after the call returns. + SendNode(ctx context.Context, nodeData []byte) error + + // DecryptDM decrypts a direct-message child node from the given sender. + // The return values mirror the library's own decrypt entry point: the + // plaintext, the 32-byte ciphertext hash (used for the decrypted-event + // buffer; may be nil), and an error. + DecryptDM(ctx context.Context, child *waBinary.Node, from types.JID, isPreKey bool) (plaintext []byte, ciphertextHash *[32]byte, err error) + + // EncryptForDevice encrypts plaintext for a single recipient device. + // The optional bundle is used to establish a session if none exists. + // The returned node is the `` stanza; the bool reports whether the + // device identity must be included alongside it. + EncryptForDevice(ctx context.Context, plaintext []byte, to types.JID, bundle *prekey.Bundle, extraAttrs waBinary.Attrs) (encNode *waBinary.Node, includeDeviceIdentity bool, err error) + + // FetchPreKeys fetches prekey bundles for the given devices, keyed by + // device JID. Devices for which no bundle could be fetched are omitted. + FetchPreKeys(ctx context.Context, devices []types.JID) (map[types.JID]*prekey.Bundle, error) + + // GetUserDevices resolves the device list for the given users. Input is + // a list of regular JIDs; output is a list of AD (device) JIDs. + GetUserDevices(ctx context.Context, users []types.JID) ([]types.JID, error) + + // GetUserInfo resolves user info (picture ID, status, verified name, + // devices) for the given JIDs. + GetUserInfo(ctx context.Context, jids []types.JID) (map[types.JID]types.UserInfo, error) + + // ResolveLID resolves the LID for a phone-number JID. + ResolveLID(ctx context.Context, pn types.JID) (types.JID, error) + + // GetPrivacyToken resolves the privacy token for a user. + GetPrivacyToken(ctx context.Context, user types.JID) (*store.PrivacyToken, error) +} + +// NewShadowClient constructs a headless [Client] driven by injected nodes and +// a pluggable [ShadowRelay], for embedding whatsmeow's protocol handling +// without a live socket. +// +// The returned value is a real *Client: its nodeHandlers map is populated +// exactly like [NewClient], so registering event handlers, injecting nodes +// via [Client.InjectNode], and using [Client.DangerousInternals] all behave +// the same as on a normal client. The differences are: +// +// - It has no socket, and [Client.Connect] is guarded: any attempt to open +// one returns [ErrShadowClientNoConnect]. +// - Its Store is the caller-provided seeded snapshot (typically a device +// with identity/session keys imported from an existing session). +// - Its send path routes marshaled nodes through relay.SendNode instead of +// a socket write. +// - Its Signal/keying entry points (decryption, per-device encryption, +// prekey fetch, device/user/LID/privacy-token resolution) consult the +// relay, since a headless client with no live session cannot perform +// them locally. +// +// deviceStore must be non-nil (it holds the seeded snapshot). relay must be +// non-nil; it is what makes the client a shadow. +func NewShadowClient(deviceStore *store.Device, relay ShadowRelay, log waLog.Logger) *Client { + cli := NewClient(deviceStore, log) + cli.shadowRelay = relay + // The device-store LID and privacy-token lookups are read directly by + // consumers (Store.LIDs.GetLIDForPN, Store.PrivacyTokens.GetPrivacyToken) + // and by the send path. Wrap the seeded stores so those reads fall back + // to the relay oracle when the seeded snapshot has no local answer. + if deviceStore != nil && relay != nil { + deviceStore.LIDs = &shadowLIDStore{inner: deviceStore.LIDs, relay: relay} + deviceStore.PrivacyTokens = &shadowPrivacyTokenStore{inner: deviceStore.PrivacyTokens, relay: relay} + } + return cli +} + +// isShadow reports whether this client is a headless shadow (has a relay and +// therefore no socket). +func (cli *Client) isShadow() bool { + return cli != nil && cli.shadowRelay != nil +} + +// InjectNode replays the normal receive-loop dispatch for an already-decoded +// binary node, as if it had arrived over a live socket. It runs the +// [RawNodeHandler] hook, the Signal-disabled message handoff, IQ response +// correlation, and the standard tag-based node handlers, in the same order a +// real socket delivery would. +// +// Unlike the live path, dispatch is synchronous: the relevant node handler +// runs (and any resulting events are emitted) before InjectNode returns. This +// is required because a shadow client never starts the background handler +// queue loop (that loop is started by Connect, which a shadow never calls). +// +// It returns an error only for programming mistakes (nil client/node) or a +// failure to parse a message envelope during the Signal-disabled handoff. +// A node with an unknown tag, or one dropped by the RawNodeHandler, is not an +// error. +func (cli *Client) InjectNode(ctx context.Context, node *waBinary.Node) error { + if cli == nil { + return ErrClientIsNil + } + if node == nil { + return ErrNilNode + } + if h := cli.RawNodeHandler; h != nil { + modified, drop := h(ctx, node) + if drop { + cli.recvLog.Debugf("RawNodeHandler dropped injected node: %s", node.XMLString()) + return nil + } + if modified != nil { + node = modified + } + } + cli.recvLog.Debugf("%s", node.XMLString()) + // Mirror handleFrame's Signal-disabled handoff so injected `` + // envelopes reach the caller that owns the Signal session. + if node.Tag == "message" && cli.DisabledFeatures.Signal { + info, err := cli.parseMessageInfo(node) + if err != nil { + return fmt.Errorf("failed to parse injected message for Signal-disabled handoff: %w", err) + } + cli.dispatchEvent(&events.UndecryptedMessage{Info: *info, Raw: node}) + return nil + } + if node.Tag == "xmlstreamend" { + return nil + } + if cli.receiveResponse(ctx, node) { + return nil + } + if handler, ok := cli.nodeHandlers[node.Tag]; ok { + // Dispatch synchronously (see doc comment): the shadow has no + // handler queue loop draining cli.handlerQueue. + handler(ctx, node) + return nil + } + if node.Tag != "ack" { + cli.recvLog.Debugf("Didn't handle injected WhatsApp node %s", node.Tag) + } + return nil +} + +// shadowLIDStore wraps a seeded [store.LIDStore] so LID reads fall back to the +// relay when the local snapshot has no mapping. Writes and reverse lookups go +// to the seeded store when present, otherwise degrade gracefully. +type shadowLIDStore struct { + inner store.LIDStore + relay ShadowRelay +} + +func (s *shadowLIDStore) GetLIDForPN(ctx context.Context, pn types.JID) (types.JID, error) { + if s.inner != nil { + if lid, err := s.inner.GetLIDForPN(ctx, pn); err == nil && !lid.IsEmpty() { + return lid, nil + } + } + return s.relay.ResolveLID(ctx, pn) +} + +func (s *shadowLIDStore) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map[types.JID]types.JID, error) { + if s.inner != nil { + return s.inner.GetManyLIDsForPNs(ctx, pns) + } + res := make(map[types.JID]types.JID, len(pns)) + for _, pn := range pns { + if lid, err := s.relay.ResolveLID(ctx, pn); err == nil && !lid.IsEmpty() { + res[pn] = lid + } + } + return res, nil +} + +func (s *shadowLIDStore) GetPNForLID(ctx context.Context, lid types.JID) (types.JID, error) { + if s.inner != nil { + return s.inner.GetPNForLID(ctx, lid) + } + return types.EmptyJID, nil +} + +func (s *shadowLIDStore) PutManyLIDMappings(ctx context.Context, mappings []store.LIDMapping) error { + if s.inner != nil { + return s.inner.PutManyLIDMappings(ctx, mappings) + } + return nil +} + +func (s *shadowLIDStore) PutLIDMapping(ctx context.Context, lid, jid types.JID) error { + if s.inner != nil { + return s.inner.PutLIDMapping(ctx, lid, jid) + } + return nil +} + +// shadowPrivacyTokenStore wraps a seeded [store.PrivacyTokenStore] so token +// reads fall back to the relay when the local snapshot has no token. +type shadowPrivacyTokenStore struct { + inner store.PrivacyTokenStore + relay ShadowRelay +} + +func (s *shadowPrivacyTokenStore) GetPrivacyToken(ctx context.Context, user types.JID) (*store.PrivacyToken, error) { + if s.inner != nil { + if tok, err := s.inner.GetPrivacyToken(ctx, user); err == nil && tok != nil { + return tok, nil + } + } + return s.relay.GetPrivacyToken(ctx, user) +} + +func (s *shadowPrivacyTokenStore) PutPrivacyTokens(ctx context.Context, tokens ...store.PrivacyToken) error { + if s.inner != nil { + return s.inner.PutPrivacyTokens(ctx, tokens...) + } + return nil +} + +func (s *shadowPrivacyTokenStore) DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error) { + if s.inner != nil { + return s.inner.DeleteExpiredPrivacyTokens(ctx, cutoff) + } + return 0, nil +} diff --git a/shadow_test.go b/shadow_test.go new file mode 100644 index 000000000..d1c729e99 --- /dev/null +++ b/shadow_test.go @@ -0,0 +1,295 @@ +// Copyright (c) 2021 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package whatsmeow + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "go.mau.fi/libsignal/keys/prekey" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// fakeShadowRelay is a test double for [ShadowRelay] that records calls. +type fakeShadowRelay struct { + mu sync.Mutex + + sentNodes [][]byte + decryptDMCalls int + encryptCalls int + fetchPreKeyCalls int + userDevicesCalls int + userInfoCalls int + resolveLIDCalls int + privacyCalls int + + sendErr error +} + +func (f *fakeShadowRelay) SendNode(ctx context.Context, nodeData []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sentNodes = append(f.sentNodes, append([]byte(nil), nodeData...)) + return f.sendErr +} + +func (f *fakeShadowRelay) DecryptDM(ctx context.Context, child *waBinary.Node, from types.JID, isPreKey bool) ([]byte, *[32]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.decryptDMCalls++ + return []byte("decrypted-by-relay"), nil, nil +} + +func (f *fakeShadowRelay) EncryptForDevice(ctx context.Context, plaintext []byte, to types.JID, bundle *prekey.Bundle, extraAttrs waBinary.Attrs) (*waBinary.Node, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.encryptCalls++ + return &waBinary.Node{Tag: "enc"}, false, nil +} + +func (f *fakeShadowRelay) FetchPreKeys(ctx context.Context, devices []types.JID) (map[types.JID]*prekey.Bundle, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.fetchPreKeyCalls++ + return map[types.JID]*prekey.Bundle{}, nil +} + +func (f *fakeShadowRelay) GetUserDevices(ctx context.Context, users []types.JID) ([]types.JID, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.userDevicesCalls++ + return users, nil +} + +func (f *fakeShadowRelay) GetUserInfo(ctx context.Context, jids []types.JID) (map[types.JID]types.UserInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.userInfoCalls++ + return map[types.JID]types.UserInfo{}, nil +} + +func (f *fakeShadowRelay) ResolveLID(ctx context.Context, pn types.JID) (types.JID, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.resolveLIDCalls++ + return types.EmptyJID, nil +} + +func (f *fakeShadowRelay) GetPrivacyToken(ctx context.Context, user types.JID) (*store.PrivacyToken, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.privacyCalls++ + return nil, nil +} + +func (f *fakeShadowRelay) sentCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sentNodes) +} + +// Compile-time assertion that the test double satisfies the interface. +var _ ShadowRelay = (*fakeShadowRelay)(nil) + +func newTestShadow(t *testing.T, relay ShadowRelay) *Client { + t.Helper() + dev := &store.Device{} + cli := NewShadowClient(dev, relay, nil) + if cli == nil { + t.Fatal("NewShadowClient returned nil") + } + return cli +} + +// TestNewShadowClientPopulatesNodeHandlers verifies the shadow is a real +// *Client whose (unexported) nodeHandlers map is populated exactly like a +// normal client, reached via reflection the way an external consumer would. +func TestNewShadowClientPopulatesNodeHandlers(t *testing.T) { + shadow := newTestShadow(t, &fakeShadowRelay{}) + normal := NewClient(&store.Device{}, nil) + + shadowHandlers := reflect.ValueOf(shadow).Elem().FieldByName("nodeHandlers") + if shadowHandlers.Kind() != reflect.Map { + t.Fatalf("nodeHandlers field is not a map, got %s", shadowHandlers.Kind()) + } + if shadowHandlers.Len() == 0 { + t.Fatal("shadow client nodeHandlers map is empty") + } + normalHandlers := reflect.ValueOf(normal).Elem().FieldByName("nodeHandlers") + if shadowHandlers.Len() != normalHandlers.Len() { + t.Fatalf("shadow nodeHandlers len %d != normal nodeHandlers len %d", shadowHandlers.Len(), normalHandlers.Len()) + } + // The "call" tag handler is what an injected node dispatches to. + if _, ok := shadow.nodeHandlers["call"]; !ok { + t.Fatal("shadow client is missing the \"call\" node handler") + } + if shadow.Store != normal.Store && shadow.Store == nil { + t.Fatal("shadow client Store is nil") + } +} + +// TestShadowConnectIsGuarded verifies a shadow can never open a socket. +func TestShadowConnectIsGuarded(t *testing.T) { + shadow := newTestShadow(t, &fakeShadowRelay{}) + if err := shadow.Connect(); !errors.Is(err, ErrShadowClientNoConnect) { + t.Fatalf("Connect() = %v, want ErrShadowClientNoConnect", err) + } + if err := shadow.ConnectContext(context.Background()); !errors.Is(err, ErrShadowClientNoConnect) { + t.Fatalf("ConnectContext() = %v, want ErrShadowClientNoConnect", err) + } + if shadow.IsConnected() { + t.Fatal("shadow client reports connected") + } +} + +// TestShadowInjectNodeDispatchesToHandler verifies InjectNode replays the +// receive-loop dispatch: a synthetic offer reaches both whatsmeow's +// own decoder (which builds the CallOffer event) and the registered handler. +func TestShadowInjectNodeDispatchesToHandler(t *testing.T) { + relay := &fakeShadowRelay{} + shadow := newTestShadow(t, relay) + // Make the deferred ack synchronous so it completes within InjectNode. + shadow.SynchronousAck = true + + from := types.JID{User: "123456", Server: types.DefaultUserServer} + creator := types.JID{User: "123456", Server: types.DefaultUserServer} + + var gotOffer *events.CallOffer + shadow.AddEventHandler(func(evt any) { + if o, ok := evt.(*events.CallOffer); ok { + gotOffer = o + } + }) + + callNode := &waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{ + "from": from, + "id": "callstanza1", + "t": "1700000000", + }, + Content: []waBinary.Node{{ + Tag: "offer", + Attrs: waBinary.Attrs{ + "call-id": "callid1", + "call-creator": creator, + }, + }}, + } + + if err := shadow.InjectNode(context.Background(), callNode); err != nil { + t.Fatalf("InjectNode returned error: %v", err) + } + if gotOffer == nil { + t.Fatal("injected node did not dispatch a CallOffer event to the registered handler") + } + if gotOffer.CallID != "callid1" { + t.Fatalf("CallOffer.CallID = %q, want %q", gotOffer.CallID, "callid1") + } + // The handler's deferred ack must route to the relay, not a socket. + if relay.sentCount() == 0 { + t.Fatal("expected the call handler's ack to be routed to the relay") + } +} + +// TestShadowInjectNilNode verifies the nil-node guard. +func TestShadowInjectNilNode(t *testing.T) { + shadow := newTestShadow(t, &fakeShadowRelay{}) + if err := shadow.InjectNode(context.Background(), nil); !errors.Is(err, ErrNilNode) { + t.Fatalf("InjectNode(nil) = %v, want ErrNilNode", err) + } +} + +// TestShadowSendNodeRoutesToRelay verifies that DangerousInternals().SendNode +// on a socketless shadow routes to the relay and never panics on the absent +// socket. +func TestShadowSendNodeRoutesToRelay(t *testing.T) { + relay := &fakeShadowRelay{} + shadow := newTestShadow(t, relay) + + node := waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": "req1", "type": "get"}, + } + if err := shadow.DangerousInternals().SendNode(context.Background(), node); err != nil { + t.Fatalf("SendNode returned error: %v", err) + } + if relay.sentCount() != 1 { + t.Fatalf("relay recorded %d sends, want 1", relay.sentCount()) + } +} + +// TestShadowSendNodeFailsClosedWithoutRelay verifies the fail-closed guard: a +// socketless client with no relay must error, never nil-panic. +func TestShadowSendNodeFailsClosedWithoutRelay(t *testing.T) { + // A normal client with no socket and no relay. + cli := NewClient(&store.Device{}, nil) + err := cli.DangerousInternals().SendNode(context.Background(), waBinary.Node{Tag: "iq"}) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("SendNode without socket/relay = %v, want ErrNotConnected", err) + } +} + +// TestShadowSignalOpsDelegateToRelay verifies the Signal/keying entry points +// consult the relay oracle instead of trying to use a socket or local session. +func TestShadowSignalOpsDelegateToRelay(t *testing.T) { + relay := &fakeShadowRelay{} + shadow := newTestShadow(t, relay) + ctx := context.Background() + jids := []types.JID{{User: "123", Server: types.DefaultUserServer}} + + devices, err := shadow.GetUserDevices(ctx, jids) + if err != nil { + t.Fatalf("GetUserDevices returned error: %v", err) + } + if relay.userDevicesCalls != 1 { + t.Fatalf("relay.GetUserDevices called %d times, want 1", relay.userDevicesCalls) + } + if len(devices) != 1 { + t.Fatalf("GetUserDevices returned %d devices, want 1", len(devices)) + } + + if _, err := shadow.GetUserInfo(ctx, jids); err != nil { + t.Fatalf("GetUserInfo returned error: %v", err) + } + if relay.userInfoCalls != 1 { + t.Fatalf("relay.GetUserInfo called %d times, want 1", relay.userInfoCalls) + } + + // Delegated decryption via DangerousInternals. + child := &waBinary.Node{Tag: "enc", Content: []byte("ciphertext")} + pt, _, err := shadow.DangerousInternals().DecryptDM(ctx, child, jids[0], false, time.Time{}) + if err != nil { + t.Fatalf("DecryptDM returned error: %v", err) + } + if string(pt) != "decrypted-by-relay" { + t.Fatalf("DecryptDM plaintext = %q, want %q", pt, "decrypted-by-relay") + } + if relay.decryptDMCalls != 1 { + t.Fatalf("relay.DecryptDM called %d times, want 1", relay.decryptDMCalls) + } +} + +// TestShadowGenerateIDsWork verifies the socketless client can still generate +// message/request IDs (no transport needed). +func TestShadowGenerateIDsWork(t *testing.T) { + shadow := newTestShadow(t, &fakeShadowRelay{}) + if shadow.GenerateMessageID() == "" { + t.Fatal("GenerateMessageID returned empty string") + } + if shadow.DangerousInternals().GenerateRequestID() == "" { + t.Fatal("GenerateRequestID returned empty string") + } +} diff --git a/user.go b/user.go index d926c000d..3f45749f3 100644 --- a/user.go +++ b/user.go @@ -270,6 +270,11 @@ func parseUSyncUsername(user waBinary.Node) string { // GetUserInfo gets basic user info (avatar, status, verified business name, device list). func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types.JID]types.UserInfo, error) { + if cli.isShadow() { + // Headless clients have no socket to run the usync IQ; delegate to + // the relay oracle. + return cli.shadowRelay.GetUserInfo(ctx, jids) + } list, err := cli.usync(ctx, jids, "full", "background", []waBinary.Node{ {Tag: "business", Content: []waBinary.Node{{Tag: "verified_name"}}}, {Tag: "status"}, @@ -639,6 +644,11 @@ func (cli *Client) GetUserDevices(ctx context.Context, jids []types.JID) ([]type if cli == nil { return nil, ErrClientIsNil } + if cli.isShadow() { + // Headless clients have no socket to run the usync IQ; delegate to + // the relay oracle. + return cli.shadowRelay.GetUserDevices(ctx, jids) + } cli.userDevicesCacheLock.Lock() defer cli.userDevicesCacheLock.Unlock() From 7c5f782f4974727529276025c09514dcfc55ee84 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 5 Sep 2026 09:04:31 +0300 Subject: [PATCH 2/5] shadow: adopt hypermeow module + libsignal-protocol-go import paths --- shadow.go | 12 ++++++------ shadow_test.go | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/shadow.go b/shadow.go index 8eabab7ae..ba8593274 100644 --- a/shadow.go +++ b/shadow.go @@ -12,13 +12,13 @@ import ( "fmt" "time" - "go.mau.fi/libsignal/keys/prekey" + "github.com/polymorfa/libsignal-protocol-go/keys/prekey" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + waLog "github.com/polymorfa/hypermeow/util/log" ) // ErrShadowClientNoConnect is returned when Connect (or any code path that diff --git a/shadow_test.go b/shadow_test.go index d1c729e99..ed408596d 100644 --- a/shadow_test.go +++ b/shadow_test.go @@ -14,12 +14,12 @@ import ( "testing" "time" - "go.mau.fi/libsignal/keys/prekey" + "github.com/polymorfa/libsignal-protocol-go/keys/prekey" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) // fakeShadowRelay is a test double for [ShadowRelay] that records calls. From 194b6e8639cb584ceb238f8b7041dab236d9cbd4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 5 Sep 2026 09:13:24 +0300 Subject: [PATCH 3/5] shadow: restore nodeHandlers dispatch table; drop XMLString hypermeow replaced whatsmeow's nodeHandlers map with a closed switch and removed Node.XMLString. The headless shadow Client needs the table to dispatch injected nodes synchronously, and embedders that hook the map via reflection (upstream-compatible) keep working. handleNode / hasNodeHandler now read the table; NewClient fills it from defaultNodeHandlers. Log lines use Node's Stringer. --- client.go | 65 ++++++++++++++++++++++++++++--------------------------- shadow.go | 4 ++-- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/client.go b/client.go index 2da5aa8c8..53fcf872b 100644 --- a/client.go +++ b/client.go @@ -157,7 +157,13 @@ type Client struct { responseWaitersLock sync.Mutex businessCatalogAuth atomic.Pointer[businessCatalogAuthState] - handlerQueue chan *waBinary.Node + handlerQueue chan *waBinary.Node + // nodeHandlers maps a top-level stanza tag to its handler. Upstream + // whatsmeow exposes the same unexported map; keeping it (instead of a + // closed switch) lets a headless shadow Client dispatch injected nodes + // synchronously and lets embedders that reach for it via reflection keep + // working. + nodeHandlers map[string]nodeHandler eventHandlers []wrappedEventHandler eventHandlersLock sync.RWMutex @@ -350,6 +356,7 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client { WebSocketHeaders: http.Header{}, } cli.paired.Store(deviceStore.ID != nil) + cli.nodeHandlers = cli.defaultNodeHandlers() return cli } @@ -1006,40 +1013,34 @@ Loop: } func (cli *Client) hasNodeHandler(tag string) bool { - switch tag { - case "message", "status", "appdata", "receipt", "call", "chatstate", "presence", "notification", "success", "failure", "stream:error", "iq", "ib": - return true - default: - return false + _, ok := cli.nodeHandlers[tag] + return ok +} + +type nodeHandler func(ctx context.Context, node *waBinary.Node) + +// defaultNodeHandlers returns the built-in top-level stanza dispatch table. +func (cli *Client) defaultNodeHandlers() map[string]nodeHandler { + return map[string]nodeHandler{ + "message": cli.handleEncryptedMessage, + "appdata": cli.handleEncryptedMessage, + "status": cli.handleUnencryptedMessage, + "receipt": cli.handleReceipt, + "call": cli.handleCallEvent, + "chatstate": cli.handleChatState, + "presence": cli.handlePresence, + "notification": cli.handleNotification, + "success": cli.handleConnectSuccess, + "failure": cli.handleConnectFailure, + "stream:error": cli.handleStreamError, + "iq": cli.handleIQ, + "ib": cli.handleIB, } } func (cli *Client) handleNode(ctx context.Context, node *waBinary.Node) { - switch node.Tag { - case "message", "appdata": - cli.handleEncryptedMessage(ctx, node) - case "status": - cli.handleUnencryptedMessage(ctx, node) - case "receipt": - cli.handleReceipt(ctx, node) - case "call": - cli.handleCallEvent(ctx, node) - case "chatstate": - cli.handleChatState(ctx, node) - case "presence": - cli.handlePresence(ctx, node) - case "notification": - cli.handleNotification(ctx, node) - case "success": - cli.handleConnectSuccess(ctx, node) - case "failure": - cli.handleConnectFailure(ctx, node) - case "stream:error": - cli.handleStreamError(ctx, node) - case "iq": - cli.handleIQ(ctx, node) - case "ib": - cli.handleIB(ctx, node) + if handler, ok := cli.nodeHandlers[node.Tag]; ok { + handler(ctx, node) } } @@ -1060,7 +1061,7 @@ func (cli *Client) sendNodeAndGetData(ctx context.Context, node waBinary.Node) ( if err != nil { return nil, fmt.Errorf("failed to marshal node: %w", err) } - cli.sendLog.Debugf("%s", node.XMLString()) + cli.sendLog.Debugf("%s", &node) return payload, cli.shadowRelay.SendNode(ctx, payload) } return nil, ErrNotConnected diff --git a/shadow.go b/shadow.go index ba8593274..a59479eaa 100644 --- a/shadow.go +++ b/shadow.go @@ -154,14 +154,14 @@ func (cli *Client) InjectNode(ctx context.Context, node *waBinary.Node) error { if h := cli.RawNodeHandler; h != nil { modified, drop := h(ctx, node) if drop { - cli.recvLog.Debugf("RawNodeHandler dropped injected node: %s", node.XMLString()) + cli.recvLog.Debugf("RawNodeHandler dropped injected node: %s", node.String()) return nil } if modified != nil { node = modified } } - cli.recvLog.Debugf("%s", node.XMLString()) + cli.recvLog.Debugf("%s", node.String()) // Mirror handleFrame's Signal-disabled handoff so injected `` // envelopes reach the caller that owns the Signal session. if node.Tag == "message" && cli.DisabledFeatures.Signal { From d94919d8400f4f5a6e14190e4ed16a12c24f7f3e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 6 Sep 2026 06:07:10 +0300 Subject: [PATCH 4/5] shadow: fail closed on group traffic, merge LID lookups, guard nil relay, keep dispatch order Addresses the review on #43: - Group (sender-key) cryptography is not delegated to the relay; a shadow now rejects skmsg decryption, sendGroup and sendGroupV3 with ErrShadowGroupUnsupported instead of creating or reading sender keys in the seeded snapshot. - shadowLIDStore.GetManyLIDsForPNs merges the seeded mappings with relay resolutions for every missing phone number. - ShadowRelay.DecryptDM documents that it must return unpadded plaintext. - NewShadowClient panics on a nil relay or device store rather than returning a client that could open a socket. - InjectNode runs handleOutOfBandNode before dispatch, like handleFrame. - Shadow GetUserDevices keeps bot JIDs local and delegates the rest. --- message.go | 5 +++++ send.go | 4 ++++ sendfb.go | 3 +++ shadow.go | 52 +++++++++++++++++++++++++++++++++++++++++++++------- user.go | 22 ++++++++++++++++++++-- 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/message.go b/message.go index 456e32199..f24f07902 100644 --- a/message.go +++ b/message.go @@ -657,6 +657,11 @@ func (cli *Client) decryptDM(ctx context.Context, child *waBinary.Node, from typ } func (cli *Client) decryptGroupMsg(ctx context.Context, child *waBinary.Node, from types.JID, chat types.JID, serverTS time.Time) ([]byte, *[32]byte, error) { + if cli.isShadow() { + // Sender-key state must never be created or read in a headless shadow; + // the relay oracle covers direct messages only. Fail closed. + return nil, nil, ErrShadowGroupUnsupported + } content, ok := child.Content.([]byte) if !ok { return nil, nil, fmt.Errorf("message content is not a byte slice") diff --git a/send.go b/send.go index 7465cf9b1..3af53db66 100644 --- a/send.go +++ b/send.go @@ -758,6 +758,10 @@ func (cli *Client) sendGroup( timings *MessageDebugTimings, extraParams nodeExtraParams, ) (string, []byte, error) { + if cli.isShadow() { + // A shadow would otherwise mint a sender key into the seeded snapshot. + return "", nil, ErrShadowGroupUnsupported + } start := time.Now() plaintext, _, err := marshalMessage(to, message) timings.Marshal = time.Since(start) diff --git a/sendfb.go b/sendfb.go index 7b5da2c22..b26bb1b24 100644 --- a/sendfb.go +++ b/sendfb.go @@ -218,6 +218,9 @@ func (cli *Client) sendGroupV3( frankingTag []byte, timings *MessageDebugTimings, ) (string, []byte, error) { + if cli.isShadow() { + return "", nil, ErrShadowGroupUnsupported + } var groupMeta *groupMetaCache var err error start := time.Now() diff --git a/shadow.go b/shadow.go index a59479eaa..6a23cef07 100644 --- a/shadow.go +++ b/shadow.go @@ -30,6 +30,13 @@ var ErrShadowClientNoConnect = errors.New("shadow client cannot open a socket; o // ErrNilNode is returned by [Client.InjectNode] when passed a nil node. var ErrNilNode = errors.New("cannot inject nil node") +// ErrShadowGroupUnsupported is returned when a headless shadow client is asked +// to encrypt or decrypt group (sender-key) traffic. The relay oracle covers +// direct-message cryptography only; a shadow must never create or hold sender +// keys locally, so group paths fail closed instead of silently using the seeded +// snapshot's Signal stores. +var ErrShadowGroupUnsupported = errors.New("shadow client does not process group (sender-key) traffic; the relay covers direct messages only") + // ShadowRelay is the pluggable backend that a headless ("shadow") [Client] // delegates real-session work to. // @@ -60,6 +67,13 @@ type ShadowRelay interface { // The return values mirror the library's own decrypt entry point: the // plaintext, the 32-byte ciphertext hash (used for the decrypted-event // buffer; may be nil), and an error. + // + // The plaintext MUST already be unpadded (the raw protobuf bytes of the + // waE2E.Message): the client hands it straight to the message parser and + // does not run its own unpadding on relay output. + // + // Group (sender-key) traffic is not delegated: a shadow client rejects + // `skmsg` decryption and group sends with [ErrShadowGroupUnsupported]. DecryptDM(ctx context.Context, child *waBinary.Node, from types.JID, isPreKey bool) (plaintext []byte, ciphertextHash *[32]byte, err error) // EncryptForDevice encrypts plaintext for a single recipient device. @@ -108,18 +122,24 @@ type ShadowRelay interface { // them locally. // // deviceStore must be non-nil (it holds the seeded snapshot). relay must be -// non-nil; it is what makes the client a shadow. +// non-nil; it is what makes the client a shadow. Both are programming +// errors when nil and panic: a nil relay would otherwise yield a client that +// is not a shadow at all and could open a real socket. func NewShadowClient(deviceStore *store.Device, relay ShadowRelay, log waLog.Logger) *Client { + if relay == nil { + panic("whatsmeow: NewShadowClient requires a non-nil ShadowRelay") + } + if deviceStore == nil { + panic("whatsmeow: NewShadowClient requires a non-nil seeded device store") + } cli := NewClient(deviceStore, log) cli.shadowRelay = relay // The device-store LID and privacy-token lookups are read directly by // consumers (Store.LIDs.GetLIDForPN, Store.PrivacyTokens.GetPrivacyToken) // and by the send path. Wrap the seeded stores so those reads fall back // to the relay oracle when the seeded snapshot has no local answer. - if deviceStore != nil && relay != nil { - deviceStore.LIDs = &shadowLIDStore{inner: deviceStore.LIDs, relay: relay} - deviceStore.PrivacyTokens = &shadowPrivacyTokenStore{inner: deviceStore.PrivacyTokens, relay: relay} - } + deviceStore.LIDs = &shadowLIDStore{inner: deviceStore.LIDs, relay: relay} + deviceStore.PrivacyTokens = &shadowPrivacyTokenStore{inner: deviceStore.PrivacyTokens, relay: relay} return cli } @@ -162,6 +182,10 @@ func (cli *Client) InjectNode(ctx context.Context, node *waBinary.Node) error { } } cli.recvLog.Debugf("%s", node.String()) + // Same order as handleFrame: out-of-band handling (business-catalog nonce + // delivery) runs before any dispatch so the normal handler sees the + // delivered state. + cli.handleOutOfBandNode(node) // Mirror handleFrame's Signal-disabled handoff so injected `` // envelopes reach the caller that owns the Signal session. if node.Tag == "message" && cli.DisabledFeatures.Signal { @@ -207,12 +231,26 @@ func (s *shadowLIDStore) GetLIDForPN(ctx context.Context, pn types.JID) (types.J return s.relay.ResolveLID(ctx, pn) } +// GetManyLIDsForPNs merges the seeded snapshot's mappings with relay +// resolutions for every phone number the snapshot does not know, so a partial +// local answer never leaves a device addressed by phone number. func (s *shadowLIDStore) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map[types.JID]types.JID, error) { + res := make(map[types.JID]types.JID, len(pns)) if s.inner != nil { - return s.inner.GetManyLIDsForPNs(ctx, pns) + local, err := s.inner.GetManyLIDsForPNs(ctx, pns) + if err != nil { + return nil, err + } + for pn, lid := range local { + if !lid.IsEmpty() { + res[pn] = lid + } + } } - res := make(map[types.JID]types.JID, len(pns)) for _, pn := range pns { + if _, ok := res[pn]; ok { + continue + } if lid, err := s.relay.ResolveLID(ctx, pn); err == nil && !lid.IsEmpty() { res[pn] = lid } diff --git a/user.go b/user.go index 3f45749f3..9ec199eaf 100644 --- a/user.go +++ b/user.go @@ -646,8 +646,26 @@ func (cli *Client) GetUserDevices(ctx context.Context, jids []types.JID) ([]type } if cli.isShadow() { // Headless clients have no socket to run the usync IQ; delegate to - // the relay oracle. - return cli.shadowRelay.GetUserDevices(ctx, jids) + // the relay oracle. Bots have no devices and are addressed as-is, so + // they never reach the relay (mirrors the local path below). Messenger + // (FB) JIDs are the relay's responsibility: it must resolve them the + // way getFBIDDevices does, or omit them. + var devices, others []types.JID + for _, jid := range jids { + if jid.IsBot() { + devices = append(devices, jid) + } else { + others = append(others, jid) + } + } + if len(others) > 0 { + resolved, err := cli.shadowRelay.GetUserDevices(ctx, others) + if err != nil { + return nil, err + } + devices = append(devices, resolved...) + } + return devices, nil } cli.userDevicesCacheLock.Lock() defer cli.userDevicesCacheLock.Unlock() From 49a8071190398dc0bbc563f55791dcdb3ca587bf Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 6 Sep 2026 06:14:38 +0300 Subject: [PATCH 5/5] shadow: no retry receipt for unsupported group traffic; reject typed-nil relays Round two of the #43 review: a shadow acknowledges and surfaces an skmsg it cannot decrypt as UndecryptableMessage instead of requesting redelivery, and NewShadowClient panics on a typed-nil relay as well as a plain nil one (constructor test added). --- message.go | 14 ++++++++++++++ shadow.go | 16 +++++++++++++++- shadow_test.go | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/message.go b/message.go index f24f07902..17f8511ca 100644 --- a/message.go +++ b/message.go @@ -437,6 +437,20 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo, } else if errors.Is(err, signalerror.ErrOldCounter) { cli.Log.Warnf("Ignoring message %s from %s: %v", info.ID, info.SourceString(), err) continue + } else if errors.Is(err, ErrShadowGroupUnsupported) { + // A shadow can never make group ciphertext decryptable, so a retry + // receipt would only provoke redeliveries. Acknowledge, surface the + // message as undecryptable, and move on. + cli.Log.Warnf("Ignoring group message %s from %s: %v", info.ID, info.SourceString(), err) + cli.backgroundIfAsyncAck(func() { + cli.sendAck(ctx, node, 0) + }) + cli.dispatchEvent(&events.UndecryptableMessage{ + Info: *info, + IsUnavailable: true, + DecryptFailMode: events.DecryptFailMode(ag.OptionalString("decrypt-fail")), + }) + continue } else if err != nil { cli.Log.Warnf("Error decrypting message %s from %s: %v", info.ID, info.SourceString(), err) if ctx.Err() != nil || errors.Is(err, context.Canceled) { diff --git a/shadow.go b/shadow.go index 6a23cef07..f70ac768e 100644 --- a/shadow.go +++ b/shadow.go @@ -10,6 +10,7 @@ import ( "context" "errors" "fmt" + "reflect" "time" "github.com/polymorfa/libsignal-protocol-go/keys/prekey" @@ -126,7 +127,7 @@ type ShadowRelay interface { // errors when nil and panic: a nil relay would otherwise yield a client that // is not a shadow at all and could open a real socket. func NewShadowClient(deviceStore *store.Device, relay ShadowRelay, log waLog.Logger) *Client { - if relay == nil { + if relay == nil || isTypedNil(relay) { panic("whatsmeow: NewShadowClient requires a non-nil ShadowRelay") } if deviceStore == nil { @@ -308,3 +309,16 @@ func (s *shadowPrivacyTokenStore) DeleteExpiredPrivacyTokens(ctx context.Context } return 0, nil } + +// isTypedNil reports whether an interface value wraps a nil pointer/map/etc. +// (a "typed nil"), which `== nil` does not catch but whose methods would panic +// on a nil receiver. +func isTypedNil(v any) bool { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.Interface: + return rv.IsNil() + default: + return false + } +} diff --git a/shadow_test.go b/shadow_test.go index ed408596d..127f0f726 100644 --- a/shadow_test.go +++ b/shadow_test.go @@ -113,6 +113,25 @@ func newTestShadow(t *testing.T, relay ShadowRelay) *Client { return cli } +// TestNewShadowClientRejectsNilInputs verifies the constructor fails loudly +// on a nil relay (plain or typed nil) or a nil store, instead of handing back +// a socket-capable non-shadow client. +func TestNewShadowClientRejectsNilInputs(t *testing.T) { + expectPanic := func(name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatalf("%s: expected NewShadowClient to panic", name) + } + }() + fn() + } + expectPanic("nil relay", func() { NewShadowClient(&store.Device{}, nil, nil) }) + var typedNil *fakeShadowRelay + expectPanic("typed-nil relay", func() { NewShadowClient(&store.Device{}, typedNil, nil) }) + expectPanic("nil store", func() { NewShadowClient(nil, &fakeShadowRelay{}, nil) }) +} + // TestNewShadowClientPopulatesNodeHandlers verifies the shadow is a real // *Client whose (unexported) nodeHandlers map is populated exactly like a // normal client, reached via reflection the way an external consumer would.