From a533e106c2e6063b711d8c0ceede2f673966ed3c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:31:19 -0700 Subject: [PATCH] appservice: keep the crypto method set on the wrapped state store The lazy store embedded appservice.StateStore, but the client's store is also type-asserted to crypto.StateStore -- a wider interface -- and NewCryptoHelper refuses to start when that assertion fails. Embedding the narrower interface dropped GetEncryptionEvent and FindSharedRooms from the concrete type, so the bot died on startup with "the client state store must implement crypto.StateStore". Embed both, assert both at compile time, and resolve GetEncryptionEvent lazily like IsEncrypted since the crypto machine reads megolm rotation settings through it. --- internal/bot/appservice.go | 6 ++++- internal/bot/statestore.go | 47 ++++++++++++++++++++++++++------- internal/bot/statestore_test.go | 20 ++++++++++++-- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/internal/bot/appservice.go b/internal/bot/appservice.go index 889026b..471919e 100644 --- a/internal/bot/appservice.go +++ b/internal/bot/appservice.go @@ -110,7 +110,11 @@ func newAppserviceSession(cfg Config) (*Session, error) { // use, since there is no /sync to backfill it. Must be installed before the // first BotClient() call: makeClient copies as.StateStore into the client, and // caches the client. - store := newLazyStateStore(as.StateStore) + inner, ok := as.StateStore.(innerStateStore) + if !ok { + return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore) + } + store := newLazyStateStore(inner) as.StateStore = store client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion diff --git a/internal/bot/statestore.go b/internal/bot/statestore.go index d5694b5..a76dcf2 100644 --- a/internal/bot/statestore.go +++ b/internal/bot/statestore.go @@ -8,6 +8,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -30,7 +31,12 @@ import ( // error, which aborts the send. A failed message is recoverable; a plaintext one // that has already landed in an encrypted room is not. type lazyStateStore struct { - appservice.StateStore + // Embeds both interfaces the store is consumed through: the appservice + // dispatches state updates through the first, and the crypto machine type- + // asserts the client's store to the second (cryptohelper.NewCryptoHelper + // rejects a store that fails the assertion). Embedding only the appservice + // interface silently drops crypto's extra methods from the concrete type. + innerStateStore client *mautrix.Client @@ -42,9 +48,22 @@ type lazyStateStore struct { encryptionResolved map[id.RoomID]bool } -func newLazyStateStore(inner appservice.StateStore) *lazyStateStore { +// innerStateStore is the full method set the wrapped store must provide. +type innerStateStore interface { + appservice.StateStore + crypto.StateStore +} + +// The wrapper is consumed through both, and losing either one is a startup +// failure rather than a build failure without these. +var ( + _ appservice.StateStore = (*lazyStateStore)(nil) + _ crypto.StateStore = (*lazyStateStore)(nil) +) + +func newLazyStateStore(inner innerStateStore) *lazyStateStore { return &lazyStateStore{ - StateStore: inner, + innerStateStore: inner, locks: make(map[id.RoomID]*sync.Mutex), encryptionResolved: make(map[id.RoomID]bool), } @@ -90,7 +109,7 @@ func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (boo switch { case err == nil: if enc.Algorithm != "" { - if err := s.StateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil { + if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil { return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err) } } @@ -103,14 +122,24 @@ func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (boo s.markEncryptionResolved(roomID) } } - return s.StateStore.IsEncrypted(ctx, roomID) + return s.innerStateStore.IsEncrypted(ctx, roomID) +} + +// GetEncryptionEvent is how the crypto machine reads a room's megolm settings +// (session rotation period, etc). It needs the same resolution as IsEncrypted, +// since an unresolved room would otherwise report no encryption event. +func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { + if _, err := s.IsEncrypted(ctx, roomID); err != nil { + return nil, err + } + return s.innerStateStore.GetEncryptionEvent(ctx, roomID) } // GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync // there is no member backfill, so an unfetched room would report zero members // and the bot would encrypt to an audience of nobody. func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) { - fetched, err := s.StateStore.HasFetchedMembers(ctx, roomID) + fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID) if err != nil { return nil, fmt.Errorf("check member cache for %s: %w", roomID, err) } @@ -118,7 +147,7 @@ func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, room unlock := s.lockRoom(roomID) defer unlock() - if fetched, err := s.StateStore.HasFetchedMembers(ctx, roomID); err != nil { + if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil { return nil, fmt.Errorf("check member cache for %s: %w", roomID, err) } else if !fetched { members, err := s.client.Members(ctx, roomID) @@ -126,10 +155,10 @@ func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, room return nil, fmt.Errorf("fetch members of %s: %w", roomID, err) } // No membership filter, so this also marks the room as fetched. - if err := s.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil { + if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil { return nil, fmt.Errorf("cache members of %s: %w", roomID, err) } } } - return s.StateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) + return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) } diff --git a/internal/bot/statestore_test.go b/internal/bot/statestore_test.go index 144c9d0..21e8acc 100644 --- a/internal/bot/statestore_test.go +++ b/internal/bot/statestore_test.go @@ -4,13 +4,14 @@ import ( "context" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "sync/atomic" "testing" "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/id" ) @@ -53,11 +54,26 @@ func (f *fakeHS) start(t *testing.T) *mautrix.Client { func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore { t.Helper() - store := newLazyStateStore(mautrix.NewMemoryStateStore().(appservice.StateStore)) + store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore)) store.client = hs.start(t) return store } +// The store is handed to the crypto helper, which type-asserts it and refuses to +// start if it comes up short. That assertion is the whole bot's startup path, so +// pin it here rather than discovering it as a crash loop in prod. +func TestStoreSatisfiesCryptoHelper(t *testing.T) { + store := newTestStore(t, &fakeHS{}) + + client := store.client + client.StateStore = store + + dir := t.TempDir() + if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil { + t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err) + } +} + const roomID = id.RoomID("!room:example.org") // The regression: a room configured as encrypted before the process started. No