723 lines
25 KiB
Go
723 lines
25 KiB
Go
package loadharness
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/tg"
|
|
)
|
|
|
|
const ClientStateVersion = 1
|
|
|
|
type ClientUpdateState struct {
|
|
Pts int `json:"pts"`
|
|
Qts int `json:"qts"`
|
|
Date int `json:"date"`
|
|
Seq int `json:"seq"`
|
|
UnreadCount int `json:"unread_count"`
|
|
}
|
|
|
|
type ClientDialogState struct {
|
|
PeerType string `json:"peer_type"`
|
|
PeerID int64 `json:"peer_id"`
|
|
AccessHash int64 `json:"access_hash"`
|
|
TopMessage int `json:"top_message"`
|
|
TopMessageDate int `json:"top_message_date"`
|
|
Pts int `json:"pts,omitempty"`
|
|
HasPts bool `json:"has_pts,omitempty"`
|
|
ReadInboxMaxID int `json:"read_inbox_max_id"`
|
|
ReadOutboxMaxID int `json:"read_outbox_max_id"`
|
|
UnreadCount int `json:"unread_count"`
|
|
UnreadMentions int `json:"unread_mentions"`
|
|
UnreadReactions int `json:"unread_reactions"`
|
|
Pinned bool `json:"pinned,omitempty"`
|
|
HasDraft bool `json:"has_draft,omitempty"`
|
|
DraftText string `json:"draft_text,omitempty"`
|
|
DatasetExpected bool `json:"dataset_expected,omitempty"`
|
|
}
|
|
|
|
type ClientAccountState struct {
|
|
AccountIndex int `json:"account_index"`
|
|
UserID int64 `json:"user_id"`
|
|
State ClientUpdateState `json:"state"`
|
|
Dialogs []ClientDialogState `json:"dialogs"`
|
|
}
|
|
|
|
type ClientState struct {
|
|
Version int `json:"version"`
|
|
DatasetSHA256 string `json:"dataset_sha256"`
|
|
SeedIdentitySHA string `json:"seed_identity_sha256"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Accounts []ClientAccountState `json:"accounts"`
|
|
}
|
|
|
|
type SnapshotConfig struct {
|
|
ManifestPath string
|
|
SessionKeyPath string
|
|
RSAKeyOverride string
|
|
DatasetPath string
|
|
SeedStatePath string
|
|
ClientStatePath string
|
|
Concurrency int
|
|
OperationTimeout time.Duration
|
|
}
|
|
|
|
type SnapshotEvent struct {
|
|
Completed int
|
|
Total int
|
|
Account int
|
|
Resumed bool
|
|
Err error
|
|
}
|
|
|
|
type SnapshotResult struct {
|
|
Accounts int
|
|
Dialogs int
|
|
Channels int
|
|
}
|
|
|
|
func (c SnapshotConfig) validate() error {
|
|
if c.ManifestPath == "" || c.SessionKeyPath == "" || c.DatasetPath == "" || c.SeedStatePath == "" || c.ClientStatePath == "" {
|
|
return errors.New("manifest, session-key, dataset, seed-state and client-state paths are required")
|
|
}
|
|
if c.Concurrency <= 0 || c.Concurrency > 64 {
|
|
return errors.New("snapshot concurrency must be between 1 and 64")
|
|
}
|
|
if c.OperationTimeout <= 0 {
|
|
return errors.New("snapshot operation timeout must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SnapshotClientState establishes the old client cursors that later offline
|
|
// mutations must advance from. Dialogs are read through public paginated RPCs;
|
|
// per-account part files make a 1,000-account snapshot safely resumable without
|
|
// rewriting the growing aggregate after every account.
|
|
func SnapshotClientState(ctx context.Context, cfg SnapshotConfig, progress func(SnapshotEvent)) (*SnapshotResult, error) {
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
manifest, err := LoadManifest(cfg.ManifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dataset, err := LoadDataset(cfg.DatasetPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets, err := seedPrimaryTargets(manifest, dataset.Config.Accounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seedState, err := LoadDatasetSeedState(cfg.SeedStatePath, dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seedJournal := &seedJournal{dataset: dataset, state: seedState}
|
|
if err := seedJournal.assertComplete(); err != nil {
|
|
return nil, fmt.Errorf("snapshot requires a complete seed: %w", err)
|
|
}
|
|
seedIdentity, err := seedIdentitySHA256(seedState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing, loadErr := LoadClientState(cfg.ClientStatePath); loadErr == nil {
|
|
if err := existing.Validate(dataset, seedState, targets); err != nil {
|
|
return nil, fmt.Errorf("existing client state does not match requested dataset: %w", err)
|
|
}
|
|
return clientStateResult(existing), nil
|
|
} else if !os.IsNotExist(loadErr) {
|
|
return nil, loadErr
|
|
}
|
|
key, err := LoadSessionKey(cfg.SessionKeyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicKey, err := loadManifestPublicKey(cfg.ManifestPath, manifest.Endpoint, cfg.RSAKeyOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accounts := make([]int, dataset.Config.Accounts)
|
|
for account := range accounts {
|
|
accounts[account] = account
|
|
}
|
|
resumed := make([]bool, len(accounts))
|
|
for _, account := range accounts {
|
|
if _, err := loadClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset, seedState, targets, account); err == nil {
|
|
resumed[account] = true
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
}
|
|
completed := 0
|
|
if err := runSeedAccountPhase(ctx, "snapshot", accounts, cfg.Concurrency, func(event SeedEvent) {
|
|
if event.Err == nil {
|
|
completed++
|
|
}
|
|
if progress != nil {
|
|
progress(SnapshotEvent{Completed: completed, Total: len(accounts), Account: event.Account, Resumed: resumed[event.Account], Err: event.Err})
|
|
}
|
|
}, func(ctx context.Context, account int) error {
|
|
if resumed[account] {
|
|
return nil
|
|
}
|
|
state, err := snapshotAccount(ctx, cfg, manifest, dataset, seedState, targets, key, publicKey, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset.PlanSHA256, seedIdentity, state)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
clientState := &ClientState{
|
|
Version: ClientStateVersion, DatasetSHA256: dataset.PlanSHA256, SeedIdentitySHA: seedIdentity, CreatedAt: time.Now().UTC(),
|
|
Accounts: make([]ClientAccountState, 0, len(accounts)),
|
|
}
|
|
for _, account := range accounts {
|
|
state, err := loadClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset, seedState, targets, account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clientState.Accounts = append(clientState.Accounts, *state)
|
|
}
|
|
if err := clientState.Validate(dataset, seedState, targets); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := WriteClientState(cfg.ClientStatePath, clientState); err != nil {
|
|
return nil, err
|
|
}
|
|
return clientStateResult(clientState), nil
|
|
}
|
|
|
|
func snapshotAccount(
|
|
ctx context.Context,
|
|
cfg SnapshotConfig,
|
|
manifest *Manifest,
|
|
dataset *Dataset,
|
|
seedState *DatasetSeedState,
|
|
targets []SessionRecord,
|
|
key [32]byte,
|
|
publicKey *rsa.PublicKey,
|
|
account int,
|
|
) (*ClientAccountState, error) {
|
|
var result *ClientAccountState
|
|
err := withAuthorizedSeedSession(ctx, SeedConfig{
|
|
ManifestPath: cfg.ManifestPath, OperationTimeout: cfg.OperationTimeout,
|
|
}, manifest, targets[account], key, publicKey, func(ctx context.Context, raw *tg.Client) error {
|
|
dialogs, err := snapshotDialogs(ctx, cfg.OperationTimeout, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expected := expectedDatasetPeers(dataset, seedState, targets, account)
|
|
for i := range dialogs {
|
|
_, dialogs[i].DatasetExpected = expected[clientPeerKey{typ: dialogs[i].PeerType, id: dialogs[i].PeerID}]
|
|
if dialogs[i].DatasetExpected {
|
|
delete(expected, clientPeerKey{typ: dialogs[i].PeerType, id: dialogs[i].PeerID})
|
|
}
|
|
}
|
|
if len(expected) != 0 {
|
|
missing := make([]string, 0, min(len(expected), 5))
|
|
for peer := range expected {
|
|
missing = append(missing, fmt.Sprintf("%s:%d", peer.typ, peer.id))
|
|
if len(missing) == 5 {
|
|
break
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
return fmt.Errorf("messages.getDialogs omitted %d expected dataset peers (sample %s)", len(expected), strings.Join(missing, ","))
|
|
}
|
|
if err := validateSeededRichDialogs(dataset, seedState, targets, account, dialogs, true); err != nil {
|
|
return fmt.Errorf("rich dialog state: %w", err)
|
|
}
|
|
stateCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout)
|
|
state, err := raw.UpdatesGetState(stateCtx)
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("updates.getState: %w", err)
|
|
}
|
|
result = &ClientAccountState{
|
|
AccountIndex: account, UserID: targets[account].UserID,
|
|
State: ClientUpdateState{Pts: state.Pts, Qts: state.Qts, Date: state.Date, Seq: state.Seq, UnreadCount: state.UnreadCount},
|
|
Dialogs: dialogs,
|
|
}
|
|
return nil
|
|
})
|
|
if err == nil && result == nil {
|
|
return nil, errors.New("snapshot session ended without producing account state")
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
type clientPeerKey struct {
|
|
typ string
|
|
id int64
|
|
}
|
|
|
|
func snapshotDialogs(ctx context.Context, timeout time.Duration, raw *tg.Client) ([]ClientDialogState, error) {
|
|
dialogs, _, err := snapshotDialogsObserved(ctx, timeout, raw, snapshotPaginationProfile, nil)
|
|
return dialogs, err
|
|
}
|
|
|
|
func snapshotDialogsObserved(
|
|
ctx context.Context,
|
|
timeout time.Duration,
|
|
raw *tg.Client,
|
|
profile dialogPaginationProfile,
|
|
observe func(string, time.Time, error),
|
|
) ([]ClientDialogState, StartupDialogsCounts, error) {
|
|
if profile.FirstLimit <= 0 || profile.SubsequentLimit <= 0 {
|
|
return nil, StartupDialogsCounts{}, errors.New("invalid dialogs pagination profile")
|
|
}
|
|
dialogsByPeer := make(map[clientPeerKey]ClientDialogState)
|
|
counts := StartupDialogsCounts{}
|
|
start := time.Now()
|
|
pinnedCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
pinned, err := raw.MessagesGetPinnedDialogs(pinnedCtx, 0)
|
|
cancel()
|
|
if observe != nil {
|
|
observe("messages.getPinnedDialogs", start, err)
|
|
}
|
|
counts.PinnedCalls++
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("messages.getPinnedDialogs: %w", err)
|
|
}
|
|
if _, err := mergeDialogPage(dialogsByPeer, pinned.Dialogs, pinned.Messages, pinned.Chats, pinned.Users, true); err != nil {
|
|
return nil, counts, err
|
|
}
|
|
pinnedPeers := make(map[clientPeerKey]struct{}, len(dialogsByPeer))
|
|
for peer := range dialogsByPeer {
|
|
pinnedPeers[peer] = struct{}{}
|
|
}
|
|
|
|
request := &tg.MessagesGetDialogsRequest{ExcludePinned: true, OffsetPeer: &tg.InputPeerEmpty{}, Limit: profile.limit(0)}
|
|
for page := 0; page < 100; page++ {
|
|
request.Limit = profile.limit(page)
|
|
start := time.Now()
|
|
rpcCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
response, err := raw.MessagesGetDialogs(rpcCtx, request)
|
|
cancel()
|
|
if observe != nil {
|
|
observe("messages.getDialogs", start, err)
|
|
if page == 0 {
|
|
observe("messages.getDialogs.first", start, err)
|
|
} else {
|
|
observe("messages.getDialogs.next", start, err)
|
|
}
|
|
}
|
|
counts.Calls++
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("messages.getDialogs page %d: %w", page+1, err)
|
|
}
|
|
var pageDialogs []tg.DialogClass
|
|
var messages []tg.MessageClass
|
|
var chats []tg.ChatClass
|
|
var users []tg.UserClass
|
|
complete := false
|
|
switch value := response.(type) {
|
|
case *tg.MessagesDialogs:
|
|
if observe != nil {
|
|
observe("messages.getDialogs.full", start, nil)
|
|
}
|
|
counts.Full++
|
|
pageDialogs, messages, chats, users, complete = value.Dialogs, value.Messages, value.Chats, value.Users, true
|
|
case *tg.MessagesDialogsSlice:
|
|
if observe != nil {
|
|
observe("messages.getDialogs.slice", start, nil)
|
|
}
|
|
counts.Slice++
|
|
pageDialogs, messages, chats, users = value.Dialogs, value.Messages, value.Chats, value.Users
|
|
case *tg.MessagesDialogsNotModified:
|
|
return nil, counts, errors.New("messages.getDialogs with hash=0 returned dialogsNotModified")
|
|
default:
|
|
return nil, counts, fmt.Errorf("messages.getDialogs returned %T", response)
|
|
}
|
|
last, overlap, err := mergeDialogPageKnownOverlap(dialogsByPeer, pageDialogs, messages, chats, users, false, pinnedPeers)
|
|
counts.PinnedOverlap += overlap
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("messages.getDialogs page %d: %w", page+1, err)
|
|
}
|
|
// A dialogsSlice is explicitly non-final. The server may enforce a
|
|
// smaller per-page cap than the client-requested limit (TDesktop asks
|
|
// for 500 after its first page while telesrv currently returns at most
|
|
// 100). Only the full constructor or an empty slice proves completion;
|
|
// treating a short slice as EOF truncates the real TDesktop workload.
|
|
if dialogsPaginationDone(complete, len(pageDialogs)) {
|
|
break
|
|
}
|
|
if last.TopMessage <= 0 || last.TopMessageDate <= 0 {
|
|
return nil, counts, fmt.Errorf("messages.getDialogs page %d has no usable offset", page+1)
|
|
}
|
|
request.OffsetDate = last.TopMessageDate
|
|
request.OffsetID = last.TopMessage
|
|
request.OffsetPeer = clientDialogInputPeer(last)
|
|
if request.OffsetPeer == nil {
|
|
return nil, counts, fmt.Errorf("messages.getDialogs page %d has invalid offset peer", page+1)
|
|
}
|
|
if page == 99 {
|
|
return nil, counts, errors.New("messages.getDialogs exceeded 100 pages")
|
|
}
|
|
}
|
|
result := make([]ClientDialogState, 0, len(dialogsByPeer))
|
|
for _, dialog := range dialogsByPeer {
|
|
result = append(result, dialog)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].PeerType != result[j].PeerType {
|
|
return result[i].PeerType < result[j].PeerType
|
|
}
|
|
return result[i].PeerID < result[j].PeerID
|
|
})
|
|
counts.Dialogs = len(result)
|
|
return result, counts, nil
|
|
}
|
|
|
|
func dialogsPaginationDone(complete bool, pageSize int) bool {
|
|
return complete || pageSize == 0
|
|
}
|
|
|
|
func mergeDialogPage(
|
|
destination map[clientPeerKey]ClientDialogState,
|
|
dialogClasses []tg.DialogClass,
|
|
messages []tg.MessageClass,
|
|
chats []tg.ChatClass,
|
|
users []tg.UserClass,
|
|
pinnedPage bool,
|
|
) (ClientDialogState, error) {
|
|
last, _, err := mergeDialogPageKnownOverlap(destination, dialogClasses, messages, chats, users, pinnedPage, nil)
|
|
return last, err
|
|
}
|
|
|
|
func mergeDialogPageKnownOverlap(
|
|
destination map[clientPeerKey]ClientDialogState,
|
|
dialogClasses []tg.DialogClass,
|
|
messages []tg.MessageClass,
|
|
chats []tg.ChatClass,
|
|
users []tg.UserClass,
|
|
pinnedPage bool,
|
|
allowedOverlap map[clientPeerKey]struct{},
|
|
) (ClientDialogState, int, error) {
|
|
accessHashes := make(map[clientPeerKey]int64, len(chats)+len(users))
|
|
for _, chat := range chats {
|
|
channel, ok := chat.(*tg.Channel)
|
|
if !ok {
|
|
continue
|
|
}
|
|
hash, ok := channel.GetAccessHash()
|
|
if ok {
|
|
accessHashes[clientPeerKey{typ: "channel", id: channel.ID}] = hash
|
|
}
|
|
}
|
|
for _, userClass := range users {
|
|
user, ok := userClass.(*tg.User)
|
|
if !ok {
|
|
continue
|
|
}
|
|
hash, ok := user.GetAccessHash()
|
|
if ok {
|
|
accessHashes[clientPeerKey{typ: "user", id: user.ID}] = hash
|
|
}
|
|
}
|
|
messageDates := make(map[string]int, len(messages))
|
|
for _, messageClass := range messages {
|
|
message, ok := messageClass.AsNotEmpty()
|
|
if !ok {
|
|
continue
|
|
}
|
|
peer, ok := clientPeerFromTG(message.GetPeerID())
|
|
if !ok {
|
|
continue
|
|
}
|
|
messageDates[clientMessageKey(peer, message.GetID())] = message.GetDate()
|
|
}
|
|
var last ClientDialogState
|
|
overlaps := 0
|
|
for _, dialogClass := range dialogClasses {
|
|
dialog, ok := dialogClass.(*tg.Dialog)
|
|
if !ok {
|
|
continue
|
|
}
|
|
peer, ok := clientPeerFromTG(dialog.Peer)
|
|
if !ok {
|
|
continue
|
|
}
|
|
hash := accessHashes[peer]
|
|
if hash == 0 {
|
|
return ClientDialogState{}, overlaps, fmt.Errorf("dialog %s:%d omitted access hash", peer.typ, peer.id)
|
|
}
|
|
pts, hasPts := dialog.GetPts()
|
|
if peer.typ == "channel" && (!hasPts || pts <= 0) {
|
|
return ClientDialogState{}, overlaps, fmt.Errorf("channel dialog %d omitted pts", peer.id)
|
|
}
|
|
date := messageDates[clientMessageKey(peer, dialog.TopMessage)]
|
|
if dialog.TopMessage <= 0 || date <= 0 {
|
|
return ClientDialogState{}, overlaps, fmt.Errorf("dialog %s:%d omitted top message payload", peer.typ, peer.id)
|
|
}
|
|
state := ClientDialogState{
|
|
PeerType: peer.typ, PeerID: peer.id, AccessHash: hash,
|
|
TopMessage: dialog.TopMessage, TopMessageDate: date, Pts: pts, HasPts: hasPts,
|
|
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
|
UnreadCount: dialog.UnreadCount, UnreadMentions: dialog.UnreadMentionsCount,
|
|
UnreadReactions: dialog.UnreadReactionsCount, Pinned: pinnedPage || dialog.Pinned,
|
|
}
|
|
if draftClass, ok := dialog.GetDraft(); ok {
|
|
if draft, ok := draftClass.(*tg.DraftMessage); ok && draft.Message != "" {
|
|
state.HasDraft, state.DraftText = true, draft.Message
|
|
}
|
|
}
|
|
if existing, exists := destination[peer]; exists {
|
|
if _, allowed := allowedOverlap[peer]; !allowed || !existing.Pinned || !state.Pinned || existing.TopMessage != state.TopMessage || existing.AccessHash != state.AccessHash {
|
|
return ClientDialogState{}, overlaps, fmt.Errorf("duplicate dialog %s:%d", peer.typ, peer.id)
|
|
}
|
|
delete(allowedOverlap, peer)
|
|
if !state.HasDraft && existing.HasDraft {
|
|
state.HasDraft, state.DraftText = existing.HasDraft, existing.DraftText
|
|
}
|
|
overlaps++
|
|
}
|
|
destination[peer] = state
|
|
last = state
|
|
}
|
|
return last, overlaps, nil
|
|
}
|
|
|
|
func clientPeerFromTG(peer tg.PeerClass) (clientPeerKey, bool) {
|
|
switch value := peer.(type) {
|
|
case *tg.PeerUser:
|
|
return clientPeerKey{typ: "user", id: value.UserID}, value.UserID > 0
|
|
case *tg.PeerChannel:
|
|
return clientPeerKey{typ: "channel", id: value.ChannelID}, value.ChannelID > 0
|
|
default:
|
|
return clientPeerKey{}, false
|
|
}
|
|
}
|
|
|
|
func clientMessageKey(peer clientPeerKey, messageID int) string {
|
|
return fmt.Sprintf("%s:%d:%d", peer.typ, peer.id, messageID)
|
|
}
|
|
|
|
func clientDialogInputPeer(dialog ClientDialogState) tg.InputPeerClass {
|
|
switch dialog.PeerType {
|
|
case "user":
|
|
return &tg.InputPeerUser{UserID: dialog.PeerID, AccessHash: dialog.AccessHash}
|
|
case "channel":
|
|
return &tg.InputPeerChannel{ChannelID: dialog.PeerID, AccessHash: dialog.AccessHash}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func expectedDatasetPeers(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account int) map[clientPeerKey]struct{} {
|
|
expected := make(map[clientPeerKey]struct{})
|
|
for _, edge := range dataset.PrivateEdges {
|
|
switch account {
|
|
case edge.SenderAccount:
|
|
expected[clientPeerKey{typ: "user", id: targets[edge.RecipientAccount].UserID}] = struct{}{}
|
|
case edge.RecipientAccount:
|
|
expected[clientPeerKey{typ: "user", id: targets[edge.SenderAccount].UserID}] = struct{}{}
|
|
}
|
|
}
|
|
for position, group := range dataset.Groups {
|
|
memberIndex := sort.SearchInts(group.MemberAccounts, account)
|
|
if memberIndex < len(group.MemberAccounts) && group.MemberAccounts[memberIndex] == account {
|
|
expected[clientPeerKey{typ: "channel", id: seedState.Groups[position].ChannelID}] = struct{}{}
|
|
}
|
|
}
|
|
return expected
|
|
}
|
|
|
|
func clientStatePartPath(clientStatePath string, account int) string {
|
|
return filepath.Join(clientStatePath+".parts", fmt.Sprintf("account-%04d.json", account))
|
|
}
|
|
|
|
type clientStatePart struct {
|
|
Version int `json:"version"`
|
|
DatasetSHA256 string `json:"dataset_sha256"`
|
|
SeedIdentitySHA string `json:"seed_identity_sha256"`
|
|
Account ClientAccountState `json:"account"`
|
|
}
|
|
|
|
func writeClientStatePart(path, datasetSHA, seedIdentity string, account *ClientAccountState) error {
|
|
if account == nil {
|
|
return errors.New("cannot write nil client account state")
|
|
}
|
|
part := clientStatePart{Version: ClientStateVersion, DatasetSHA256: datasetSHA, SeedIdentitySHA: seedIdentity, Account: *account}
|
|
data, err := json.MarshalIndent(part, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(path, append(data, '\n'), 0o600)
|
|
}
|
|
|
|
func loadClientStatePart(path string, dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account int) (*ClientAccountState, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var part clientStatePart
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&part); err != nil {
|
|
return nil, fmt.Errorf("decode client state part: %w", err)
|
|
}
|
|
seedIdentity, err := seedIdentitySHA256(seedState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target := targets[account]
|
|
if part.Version != ClientStateVersion || part.DatasetSHA256 != dataset.PlanSHA256 || part.SeedIdentitySHA != seedIdentity || part.Account.AccountIndex != target.AccountIndex || part.Account.UserID != target.UserID {
|
|
return nil, errors.New("client state part does not match dataset or account")
|
|
}
|
|
if err := validateClientAccountState(&part.Account); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateExpectedDatasetPeers(dataset, seedState, targets, &part.Account); err != nil {
|
|
return nil, err
|
|
}
|
|
return &part.Account, nil
|
|
}
|
|
|
|
func (s *ClientState) Validate(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord) error {
|
|
if s == nil || s.Version != ClientStateVersion || dataset == nil || s.DatasetSHA256 != dataset.PlanSHA256 {
|
|
return errors.New("client state does not match dataset")
|
|
}
|
|
seedIdentity, err := seedIdentitySHA256(seedState)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.SeedIdentitySHA != seedIdentity {
|
|
return errors.New("client state does not match seeded channel identities")
|
|
}
|
|
if len(s.Accounts) != dataset.Config.Accounts || len(targets) != dataset.Config.Accounts {
|
|
return errors.New("client state account count does not match dataset")
|
|
}
|
|
for account := range s.Accounts {
|
|
if s.Accounts[account].AccountIndex != account || s.Accounts[account].UserID != targets[account].UserID {
|
|
return fmt.Errorf("client state account %d has wrong identity", account)
|
|
}
|
|
if err := validateClientAccountState(&s.Accounts[account]); err != nil {
|
|
return fmt.Errorf("client state account %d: %w", account, err)
|
|
}
|
|
if err := validateExpectedDatasetPeers(dataset, seedState, targets, &s.Accounts[account]); err != nil {
|
|
return fmt.Errorf("client state account %d: %w", account, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateExpectedDatasetPeers(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account *ClientAccountState) error {
|
|
expected := expectedDatasetPeers(dataset, seedState, targets, account.AccountIndex)
|
|
for _, dialog := range account.Dialogs {
|
|
peer := clientPeerKey{typ: dialog.PeerType, id: dialog.PeerID}
|
|
_, shouldBeExpected := expected[peer]
|
|
if dialog.DatasetExpected != shouldBeExpected {
|
|
return fmt.Errorf("dialog %s:%d has incorrect dataset_expected marker", peer.typ, peer.id)
|
|
}
|
|
if shouldBeExpected {
|
|
delete(expected, peer)
|
|
}
|
|
}
|
|
if len(expected) != 0 {
|
|
return fmt.Errorf("client state omits %d expected dataset peers", len(expected))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func seedIdentitySHA256(state *DatasetSeedState) (string, error) {
|
|
if state == nil {
|
|
return "", errors.New("nil seed state")
|
|
}
|
|
type identity struct {
|
|
GroupIndex int `json:"group_index"`
|
|
ChannelID int64 `json:"channel_id"`
|
|
AccessHash int64 `json:"access_hash"`
|
|
}
|
|
identities := make([]identity, len(state.Groups))
|
|
for i, group := range state.Groups {
|
|
if group.ChannelID <= 0 || group.AccessHash == 0 {
|
|
return "", fmt.Errorf("group %d has incomplete identity", group.GroupIndex)
|
|
}
|
|
identities[i] = identity{GroupIndex: group.GroupIndex, ChannelID: group.ChannelID, AccessHash: group.AccessHash}
|
|
}
|
|
data, err := json.Marshal(identities)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return fmt.Sprintf("%x", sum[:]), nil
|
|
}
|
|
|
|
func validateClientAccountState(account *ClientAccountState) error {
|
|
if account == nil || account.AccountIndex < 0 || account.UserID <= 0 || account.State.Pts < 0 || account.State.Qts < 0 || account.State.Date <= 0 || account.State.Seq < 0 {
|
|
return errors.New("invalid account state")
|
|
}
|
|
seen := make(map[clientPeerKey]struct{}, len(account.Dialogs))
|
|
for _, dialog := range account.Dialogs {
|
|
peer := clientPeerKey{typ: dialog.PeerType, id: dialog.PeerID}
|
|
if (peer.typ != "user" && peer.typ != "channel") || peer.id <= 0 || dialog.AccessHash == 0 || dialog.TopMessage <= 0 || dialog.TopMessageDate <= 0 {
|
|
return fmt.Errorf("invalid dialog %s:%d", peer.typ, peer.id)
|
|
}
|
|
if peer.typ == "channel" && (!dialog.HasPts || dialog.Pts <= 0) {
|
|
return fmt.Errorf("invalid channel pts for %d", peer.id)
|
|
}
|
|
if dialog.HasDraft != (dialog.DraftText != "") {
|
|
return fmt.Errorf("invalid draft state for %s:%d", peer.typ, peer.id)
|
|
}
|
|
if _, exists := seen[peer]; exists {
|
|
return fmt.Errorf("duplicate dialog %s:%d", peer.typ, peer.id)
|
|
}
|
|
seen[peer] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func WriteClientState(path string, state *ClientState) error {
|
|
if state == nil || state.Version != ClientStateVersion || state.DatasetSHA256 == "" || state.SeedIdentitySHA == "" {
|
|
return errors.New("invalid client state")
|
|
}
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(path, append(data, '\n'), 0o600)
|
|
}
|
|
|
|
func LoadClientState(path string) (*ClientState, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var state ClientState
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&state); err != nil {
|
|
return nil, fmt.Errorf("decode client state: %w", err)
|
|
}
|
|
if state.Version != ClientStateVersion || state.DatasetSHA256 == "" || state.SeedIdentitySHA == "" {
|
|
return nil, errors.New("invalid client state")
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
func clientStateResult(state *ClientState) *SnapshotResult {
|
|
result := &SnapshotResult{Accounts: len(state.Accounts)}
|
|
for _, account := range state.Accounts {
|
|
result.Dialogs += len(account.Dialogs)
|
|
for _, dialog := range account.Dialogs {
|
|
if dialog.PeerType == "channel" {
|
|
result.Channels++
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|