LCOV - code coverage report
Current view: top level - src/app - session_store.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 93.8 % 211 198
Test Date: 2026-04-20 19:54:24 Functions: 100.0 % 16 16

            Line data    Source code
       1              : /* SPDX-License-Identifier: GPL-3.0-or-later */
       2              : /* Copyright 2026 Peter Csaszar */
       3              : 
       4              : /**
       5              :  * @file app/session_store.c
       6              :  * @brief Multi-DC session persistence (v2).
       7              :  *
       8              :  * Write safety:
       9              :  *   - An exclusive advisory lock (flock LOCK_EX | LOCK_NB) is acquired on
      10              :  *     the session file before every read-modify-write cycle.  A non-blocking
      11              :  *     attempt is used; if the lock is busy we return -1 with a log message
      12              :  *     so the caller can surface "another tg-cli process is using this session".
      13              :  *   - The new content is written to `session.bin.tmp`, fsync'd, then renamed
      14              :  *     atomically over `session.bin`.  This prevents a truncated file on crash
      15              :  *     or disk-full.
      16              :  *   - Reads also take a shared lock (LOCK_SH | LOCK_NB) so they never observe
      17              :  *     a partially-written file.
      18              :  *   - On Windows the lock calls are compiled out (advisory locks are not
      19              :  *     available via flock on MinGW); the atomic-rename pattern still applies.
      20              :  */
      21              : 
      22              : #include "app/session_store.h"
      23              : 
      24              : #include "fs_util.h"
      25              : #include "logger.h"
      26              : #include "platform/path.h"
      27              : #include "raii.h"
      28              : 
      29              : #include <errno.h>
      30              : #include <fcntl.h>
      31              : #include <stdio.h>
      32              : #include <stdlib.h>
      33              : #include <string.h>
      34              : #include <unistd.h>
      35              : 
      36              : #if !defined(_WIN32)
      37              : #  include <sys/file.h>   /* flock(2) */
      38              : #endif
      39              : 
      40              : #define STORE_MAGIC      "TGCS"
      41              : /* Current on-disk schema version. Increment (and teach the loader the previous
      42              :  * layout) whenever the header or entry format changes. Older files are
      43              :  * silently upgraded on the next save; newer ("future") files are refused
      44              :  * with a distinct diagnostic so an out-of-date client does not clobber
      45              :  * session state it cannot safely parse. */
      46              : #define STORE_VERSION        2
      47              : /* Historical single-DC layout predating multi-DC support (US-16 landing).
      48              :  *   4 bytes magic "TGCS"
      49              :  *   4 bytes version = 1
      50              :  *   4 bytes dc_id         (int32 LE)
      51              :  *   8 bytes server_salt   (uint64 LE)
      52              :  *   8 bytes session_id    (uint64 LE)
      53              :  * 256 bytes auth_key
      54              :  *
      55              :  * The whole payload is exactly 284 bytes. Retained as a read-only
      56              :  * compatibility path — on load the entry is lifted into the v2 multi-DC
      57              :  * struct, marked as home_dc, and the next successful save() atomically
      58              :  * rewrites the file in v2 format. */
      59              : #define STORE_VERSION_V1     1
      60              : #define STORE_V1_TOTAL_SIZE  284
      61              : #define STORE_HEADER     16                  /* magic+ver+home_dc+count */
      62              : #define STORE_ENTRY_SIZE 276                 /* 4 + 8 + 8 + 256 */
      63              : #define STORE_MAX_SIZE   (STORE_HEADER + SESSION_STORE_MAX_DCS * STORE_ENTRY_SIZE)
      64              : 
      65              : typedef struct {
      66              :     int32_t  dc_id;
      67              :     uint64_t server_salt;
      68              :     uint64_t session_id;
      69              :     uint8_t  auth_key[MTPROTO_AUTH_KEY_SIZE];
      70              : } StoreEntry;
      71              : 
      72              : typedef struct {
      73              :     int32_t    home_dc_id;
      74              :     uint32_t   count;
      75              :     StoreEntry entries[SESSION_STORE_MAX_DCS];
      76              : } StoreFile;
      77              : 
      78              : /* -------------------------------------------------------------------------
      79              :  * Path helpers
      80              :  * ---------------------------------------------------------------------- */
      81              : 
      82         1349 : static char *store_path(void) {
      83         1349 :     const char *cfg = platform_config_dir();
      84         1349 :     if (!cfg) return NULL;
      85         1349 :     char *p = NULL;
      86         1349 :     if (asprintf(&p, "%s/tg-cli/session.bin", cfg) == -1) return NULL;
      87         1349 :     return p;
      88              : }
      89              : 
      90          259 : static char *store_tmp_path(void) {
      91          259 :     const char *cfg = platform_config_dir();
      92          259 :     if (!cfg) return NULL;
      93          259 :     char *p = NULL;
      94          259 :     if (asprintf(&p, "%s/tg-cli/session.bin.tmp", cfg) == -1) return NULL;
      95          259 :     return p;
      96              : }
      97              : 
      98          262 : static int ensure_dir(void) {
      99          262 :     const char *cfg_dir = platform_config_dir();
     100          262 :     if (!cfg_dir) return -1;
     101              :     char dir_path[1024];
     102          262 :     snprintf(dir_path, sizeof(dir_path), "%s/tg-cli", cfg_dir);
     103          262 :     if (fs_mkdir_p(dir_path, 0700) != 0) {
     104            1 :         logger_log(LOG_ERROR, "session_store: cannot create %s", dir_path);
     105            1 :         return -1;
     106              :     }
     107          261 :     return 0;
     108              : }
     109              : 
     110              : /* -------------------------------------------------------------------------
     111              :  * Advisory locking (POSIX only)
     112              :  *
     113              :  * Returns an open fd that holds the lock, or -1 on error / busy.
     114              :  * The caller must close() the fd to release the lock.
     115              :  * On Windows these stubs always succeed (no-op).
     116              :  * ---------------------------------------------------------------------- */
     117              : 
     118              : #if !defined(_WIN32)
     119              : 
     120              : /**
     121              :  * @brief Open @p path and acquire an advisory flock.
     122              :  *
     123              :  * @param path   Path to lock (created if absent).
     124              :  * @param how    LOCK_EX for exclusive, LOCK_SH for shared.
     125              :  * @return open fd with lock held, or -1 on failure.
     126              :  */
     127          544 : static int lock_file(const char *path, int how) {
     128              :     /* O_CREAT so the lock file can exist even before first write. */
     129          544 :     int fd = open(path, O_CREAT | O_RDWR, 0600);
     130          544 :     if (fd == -1) {
     131            1 :         logger_log(LOG_ERROR, "session_store: open(%s) failed: %s",
     132            1 :                    path, strerror(errno));
     133            1 :         return -1;
     134              :     }
     135          543 :     if (flock(fd, how | LOCK_NB) == -1) {
     136            1 :         if (errno == EWOULDBLOCK || errno == EAGAIN) {
     137            1 :             logger_log(LOG_ERROR,
     138              :                        "session_store: another tg-cli process is using "
     139              :                        "this session; please close it first");
     140              :         } else {
     141            0 :             logger_log(LOG_ERROR, "session_store: flock failed: %s",
     142            0 :                        strerror(errno));
     143              :         }
     144            1 :         close(fd);
     145            1 :         return -1;
     146              :     }
     147          542 :     return fd;
     148              : }
     149              : 
     150          542 : static void unlock_file(int fd) {
     151          542 :     if (fd >= 0) close(fd);
     152          542 : }
     153              : 
     154              : #else /* _WIN32 — no advisory locks; just return a dummy fd */
     155              : 
     156              : static int lock_file(const char *path, int how) {
     157              :     (void)path; (void)how;
     158              :     return 0;   /* non-negative = success */
     159              : }
     160              : 
     161              : static void unlock_file(int fd) {
     162              :     (void)fd;
     163              : }
     164              : 
     165              : #endif /* _WIN32 */
     166              : 
     167              : /* -------------------------------------------------------------------------
     168              :  * Serialise / deserialise
     169              :  * ---------------------------------------------------------------------- */
     170              : 
     171              : /* Read the file into `out` if present.  The caller is responsible for holding
     172              :  * a shared lock before calling this function.
     173              :  *
     174              :  * Returns:
     175              :  *   0  on success (file existed and parsed cleanly)
     176              :  *  +1  on "file absent" (caller treats as empty store)
     177              :  *  -1  on corrupt / unsupported
     178              :  */
     179          542 : static int read_file_locked(StoreFile *out) {
     180          542 :     memset(out, 0, sizeof(*out));
     181              : 
     182         1084 :     RAII_STRING char *path = store_path();
     183          542 :     if (!path) return -1;
     184              : 
     185         1084 :     RAII_FILE FILE *f = fopen(path, "rb");
     186          542 :     if (!f) return +1;
     187              : 
     188              :     uint8_t buf[STORE_MAX_SIZE];
     189          542 :     size_t n = fread(buf, 1, sizeof(buf), f);
     190          542 :     if (n < STORE_HEADER) {
     191          142 :         logger_log(LOG_WARN, "session_store: truncated header");
     192          142 :         return -1;
     193              :     }
     194          400 :     if (memcmp(buf, STORE_MAGIC, 4) != 0) {
     195            1 :         logger_log(LOG_WARN, "session_store: bad magic");
     196            1 :         return -1;
     197              :     }
     198              :     int32_t version;
     199          399 :     memcpy(&version, buf + 4, 4);
     200              :     /* Legacy v1 single-DC payload — lift into a v2-shaped in-memory store so
     201              :      * the rest of the code (and the next save) is version-agnostic. The file
     202              :      * on disk is left untouched until an explicit save rewrites it atomically
     203              :      * in v2 format, which preserves the migration's crash-safety: if the
     204              :      * client exits between load and save, the v1 bytes remain usable. */
     205          399 :     if (version == STORE_VERSION_V1) {
     206            8 :         if (n < STORE_V1_TOTAL_SIZE) {
     207            1 :             logger_log(LOG_WARN, "session_store: truncated v1 payload");
     208            1 :             return -1;
     209              :         }
     210              :         int32_t  dc_id;
     211              :         uint64_t server_salt;
     212              :         uint64_t session_id;
     213            7 :         memcpy(&dc_id,       buf + 8,  4);
     214            7 :         memcpy(&server_salt, buf + 12, 8);
     215            7 :         memcpy(&session_id,  buf + 20, 8);
     216            7 :         out->home_dc_id = dc_id;
     217            7 :         out->count      = 1;
     218            7 :         out->entries[0].dc_id       = dc_id;
     219            7 :         out->entries[0].server_salt = server_salt;
     220            7 :         out->entries[0].session_id  = session_id;
     221            7 :         memcpy(out->entries[0].auth_key, buf + 28, MTPROTO_AUTH_KEY_SIZE);
     222            7 :         logger_log(LOG_INFO,
     223              :                    "session_store: migrated v1 file for DC%d "
     224              :                    "(will rewrite as v2 on next save)", dc_id);
     225            7 :         return 0;
     226              :     }
     227          391 :     if (version != STORE_VERSION) {
     228              :         /* Either an out-of-bounds garbage number (classic corruption) or a
     229              :          * *future* version that a newer client wrote.  In the latter case
     230              :          * the safe reaction is to refuse the load and never overwrite — we
     231              :          * ask the operator to upgrade the client instead of silently
     232              :          * clobbering their real session with a freshly-re-authenticated
     233              :          * v2 one. Both paths share the "unsupported version" prefix so
     234              :          * existing corruption-recovery assertions keep matching. */
     235            3 :         if (version > STORE_VERSION) {
     236            2 :             logger_log(LOG_WARN,
     237              :                        "session_store: unsupported version %d "
     238              :                        "— unknown session version, upgrade client",
     239              :                        version);
     240              :         } else {
     241            1 :             logger_log(LOG_WARN,
     242              :                        "session_store: unsupported version %d", version);
     243              :         }
     244            3 :         return -1;
     245              :     }
     246          388 :     memcpy(&out->home_dc_id, buf + 8,  4);
     247          388 :     memcpy(&out->count,      buf + 12, 4);
     248          388 :     if (out->count > SESSION_STORE_MAX_DCS) {
     249            1 :         logger_log(LOG_WARN, "session_store: count %u too large", out->count);
     250            1 :         return -1;
     251              :     }
     252          387 :     size_t need = STORE_HEADER + (size_t)out->count * STORE_ENTRY_SIZE;
     253          387 :     if (n < need) {
     254            1 :         logger_log(LOG_WARN, "session_store: truncated body");
     255            1 :         return -1;
     256              :     }
     257          902 :     for (uint32_t i = 0; i < out->count; i++) {
     258          516 :         size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
     259          516 :         memcpy(&out->entries[i].dc_id,       buf + off + 0,   4);
     260          516 :         memcpy(&out->entries[i].server_salt, buf + off + 4,   8);
     261          516 :         memcpy(&out->entries[i].session_id,  buf + off + 12,  8);
     262          516 :         memcpy( out->entries[i].auth_key,    buf + off + 20,  256);
     263              :     }
     264          386 :     return 0;
     265              : }
     266              : 
     267              : /**
     268              :  * @brief Atomically write @p st to the session file.
     269              :  *
     270              :  * Writes to a sibling .tmp file, fsync's it, then renames it over the real
     271              :  * path.  The rename is atomic on POSIX.  The caller must hold an exclusive
     272              :  * lock before calling this function.
     273              :  */
     274          259 : static int write_file_atomic(const StoreFile *st) {
     275          518 :     RAII_STRING char *path     = store_path();
     276          518 :     RAII_STRING char *tmp_path = store_tmp_path();
     277          259 :     if (!path || !tmp_path) return -1;
     278              : 
     279              :     /* Build the serialised buffer. */
     280              :     uint8_t buf[STORE_MAX_SIZE];
     281          259 :     memset(buf, 0, sizeof(buf));
     282          259 :     memcpy(buf, STORE_MAGIC, 4);
     283          259 :     int32_t version = STORE_VERSION;
     284          259 :     memcpy(buf + 4,  &version,        4);
     285          259 :     memcpy(buf + 8,  &st->home_dc_id, 4);
     286          259 :     memcpy(buf + 12, &st->count,      4);
     287          579 :     for (uint32_t i = 0; i < st->count; i++) {
     288          320 :         size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
     289          320 :         memcpy(buf + off + 0,   &st->entries[i].dc_id,       4);
     290          320 :         memcpy(buf + off + 4,   &st->entries[i].server_salt, 8);
     291          320 :         memcpy(buf + off + 12,  &st->entries[i].session_id,  8);
     292          320 :         memcpy(buf + off + 20,   st->entries[i].auth_key,    256);
     293              :     }
     294          259 :     size_t total = STORE_HEADER + (size_t)st->count * STORE_ENTRY_SIZE;
     295              : 
     296              :     /* Write to tmp. */
     297          259 :     int tfd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
     298          259 :     if (tfd == -1) {
     299            2 :         logger_log(LOG_ERROR, "session_store: cannot open tmp %s: %s",
     300            2 :                    tmp_path, strerror(errno));
     301            2 :         return -1;
     302              :     }
     303              : 
     304          257 :     ssize_t n = write(tfd, buf, total);
     305          257 :     if (n < 0 || (size_t)n != total) {
     306            0 :         logger_log(LOG_ERROR, "session_store: short write to %s", tmp_path);
     307            0 :         close(tfd);
     308            0 :         unlink(tmp_path);
     309            0 :         return -1;
     310              :     }
     311              : 
     312              : #if !defined(_WIN32)
     313          257 :     if (fsync(tfd) != 0) {
     314            0 :         logger_log(LOG_WARN, "session_store: fsync(%s) failed: %s",
     315            0 :                    tmp_path, strerror(errno));
     316              :         /* Non-fatal — proceed with rename. */
     317              :     }
     318              : #endif
     319          257 :     close(tfd);
     320              : 
     321              :     /* Set permissions on the tmp file before rename. */
     322          257 :     if (fs_ensure_permissions(tmp_path, 0600) != 0) {
     323            0 :         logger_log(LOG_WARN, "session_store: cannot set 0600 on %s", tmp_path);
     324              :     }
     325              : 
     326              :     /* Atomic rename. */
     327          257 :     if (rename(tmp_path, path) != 0) {
     328            0 :         logger_log(LOG_ERROR, "session_store: rename(%s, %s) failed: %s",
     329            0 :                    tmp_path, path, strerror(errno));
     330            0 :         unlink(tmp_path);
     331            0 :         return -1;
     332              :     }
     333          257 :     return 0;
     334              : }
     335              : 
     336              : /* -------------------------------------------------------------------------
     337              :  * Internal entry helpers
     338              :  * ---------------------------------------------------------------------- */
     339              : 
     340              : /* Find the index of @p dc_id in the store, or -1 if absent. */
     341          532 : static int find_entry(const StoreFile *st, int dc_id) {
     342          612 :     for (uint32_t i = 0; i < st->count; i++) {
     343          452 :         if (st->entries[i].dc_id == dc_id) return (int)i;
     344              :     }
     345          160 :     return -1;
     346              : }
     347              : 
     348          259 : static void populate_entry(StoreEntry *e, int dc_id, const MtProtoSession *s) {
     349          259 :     e->dc_id       = dc_id;
     350          259 :     e->server_salt = s->server_salt;
     351          259 :     e->session_id  = s->session_id;
     352          259 :     memcpy(e->auth_key, s->auth_key, MTPROTO_AUTH_KEY_SIZE);
     353          259 : }
     354              : 
     355          270 : static void apply_entry(MtProtoSession *s, const StoreEntry *e) {
     356          270 :     s->server_salt  = e->server_salt;
     357          270 :     s->session_id   = e->session_id;
     358          270 :     memcpy(s->auth_key, e->auth_key, MTPROTO_AUTH_KEY_SIZE);
     359          270 :     s->has_auth_key = 1;
     360          270 :     s->seq_no       = 0;
     361          270 :     s->last_msg_id  = 0;
     362          270 : }
     363              : 
     364              : /* -------------------------------------------------------------------------
     365              :  * Upsert (read-modify-write under exclusive lock)
     366              :  * ---------------------------------------------------------------------- */
     367              : 
     368          262 : static int upsert(int dc_id, const MtProtoSession *s, int set_home) {
     369          262 :     if (!s || !s->has_auth_key) return -1;
     370              : 
     371          262 :     if (ensure_dir() != 0) return -1;
     372              : 
     373          522 :     RAII_STRING char *path = store_path();
     374          261 :     if (!path) return -1;
     375              : 
     376              :     /* Acquire exclusive lock. */
     377          261 :     int lock_fd = lock_file(path, LOCK_EX);
     378          261 :     if (lock_fd == -1) return -1;
     379              : 
     380              :     StoreFile st;
     381          260 :     int rc = read_file_locked(&st);
     382          260 :     if (rc < 0) {
     383              :         /* Corrupt: start fresh. The user is re-authenticating anyway. */
     384          139 :         memset(&st, 0, sizeof(st));
     385              :     }
     386              : 
     387          260 :     int idx = find_entry(&st, dc_id);
     388          260 :     if (idx < 0) {
     389          158 :         if (st.count >= SESSION_STORE_MAX_DCS) {
     390            1 :             logger_log(LOG_ERROR,
     391              :                        "session_store: no slot left for DC%d", dc_id);
     392            1 :             unlock_file(lock_fd);
     393            1 :             return -1;
     394              :         }
     395          157 :         idx = (int)st.count++;
     396              :     }
     397          259 :     populate_entry(&st.entries[idx], dc_id, s);
     398              : 
     399          259 :     if (set_home || st.home_dc_id == 0) {
     400          237 :         st.home_dc_id = dc_id;
     401              :     }
     402              : 
     403          259 :     int write_rc = write_file_atomic(&st);
     404              : 
     405          259 :     unlock_file(lock_fd);
     406              : 
     407          259 :     if (write_rc != 0) return -1;
     408              : 
     409          257 :     logger_log(LOG_INFO,
     410              :                "session_store: persisted DC%d (home=%d, count=%u)",
     411              :                dc_id, st.home_dc_id, st.count);
     412          257 :     return 0;
     413              : }
     414              : 
     415              : /* -------------------------------------------------------------------------
     416              :  * Public API
     417              :  * ---------------------------------------------------------------------- */
     418              : 
     419          238 : int session_store_save(const MtProtoSession *s, int dc_id) {
     420          238 :     return upsert(dc_id, s, /*set_home=*/1);
     421              : }
     422              : 
     423           24 : int session_store_save_dc(int dc_id, const MtProtoSession *s) {
     424           24 :     return upsert(dc_id, s, /*set_home=*/0);
     425              : }
     426              : 
     427          257 : int session_store_load(MtProtoSession *s, int *dc_id) {
     428          257 :     if (!s || !dc_id) return -1;
     429              : 
     430          514 :     RAII_STRING char *path = store_path();
     431          257 :     if (!path) return -1;
     432              : 
     433              :     /* Shared lock — wait for any in-progress write to finish. */
     434          257 :     int lock_fd = lock_file(path, LOCK_SH);
     435          257 :     if (lock_fd == -1) return -1;
     436              : 
     437              :     StoreFile st;
     438          256 :     int rc = read_file_locked(&st);
     439              : 
     440          256 :     unlock_file(lock_fd);
     441              : 
     442          256 :     if (rc != 0) return -1;
     443          246 :     if (st.count == 0 || st.home_dc_id == 0) return -1;
     444              : 
     445          246 :     int idx = find_entry(&st, st.home_dc_id);
     446          246 :     if (idx < 0) {
     447            1 :         logger_log(LOG_WARN,
     448              :                    "session_store: home DC%d has no entry", st.home_dc_id);
     449            1 :         return -1;
     450              :     }
     451          245 :     apply_entry(s, &st.entries[idx]);
     452          245 :     *dc_id = st.home_dc_id;
     453          245 :     logger_log(LOG_INFO, "session_store: loaded home DC%d", *dc_id);
     454          245 :     return 0;
     455              : }
     456              : 
     457           26 : int session_store_load_dc(int dc_id, MtProtoSession *s) {
     458           26 :     if (!s) return -1;
     459              : 
     460           52 :     RAII_STRING char *path = store_path();
     461           26 :     if (!path) return -1;
     462              : 
     463           26 :     int lock_fd = lock_file(path, LOCK_SH);
     464           26 :     if (lock_fd == -1) return -1;
     465              : 
     466              :     StoreFile st;
     467           26 :     int rc = read_file_locked(&st);
     468              : 
     469           26 :     unlock_file(lock_fd);
     470              : 
     471           26 :     if (rc != 0) return -1;
     472              : 
     473           26 :     int idx = find_entry(&st, dc_id);
     474           26 :     if (idx < 0) return -1;
     475              : 
     476           25 :     apply_entry(s, &st.entries[idx]);
     477           25 :     logger_log(LOG_INFO, "session_store: loaded DC%d", dc_id);
     478           25 :     return 0;
     479              : }
     480              : 
     481            4 : void session_store_clear(void) {
     482            8 :     RAII_STRING char *path = store_path();
     483            4 :     if (!path) return;
     484            4 :     if (remove(path) == 0)
     485            4 :         logger_log(LOG_INFO, "session_store: cleared");
     486              : }
        

Generated by: LCOV version 2.0-1