LCOV - code coverage report
Current view: top level - tests/functional - test_session_migration.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 100.0 % 275 275
Test Date: 2026-04-20 19:54:22 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 test_session_migration.c
       6              :  * @brief TEST-83 / US-32 — functional coverage of session.bin schema
       7              :  *        migration (legacy v1 → current v2) and future-version rejection.
       8              :  *
       9              :  * Scenarios covered:
      10              :  *   1. test_v1_file_loads_into_v2_in_memory
      11              :  *      Hand-craft a valid legacy v1 session.bin (single DC, 284 bytes);
      12              :  *      session_store_load populates the in-memory MtProtoSession with the
      13              :  *      auth_key / salt / session_id and reports the v1-recorded dc_id.
      14              :  *   2. test_v1_file_rewritten_as_v2_on_save
      15              :  *      After the v1 load succeeds the next session_store_save atomically
      16              :  *      rewrites session.bin in v2 format — magic + version byte + multi-DC
      17              :  *      structure — with mode 0600 preserved.
      18              :  *   3. test_crash_between_v1_load_and_v2_save_keeps_v1
      19              :  *      Simulate an "atomic-rename fails" crash between load and save by
      20              :  *      making the save path unwritable (plant a directory where the
      21              :  *      session.bin.tmp is created). The save returns non-zero; the
      22              :  *      original v1 bytes remain on disk, so the next run retries.
      23              :  *   4. test_future_v3_file_rejected_without_clobber
      24              :  *      Plant a fake-future v3 file; session_store_load fails with the
      25              :  *      "unknown session version — upgrade client" diagnostic, and a
      26              :  *      byte-for-byte snapshot proves the load path leaves the file
      27              :  *      untouched (a newer client's state is never silently overwritten).
      28              :  *
      29              :  * Each test runs inside its own /tmp scratch $HOME.  XDG_CONFIG_HOME /
      30              :  * XDG_CACHE_HOME are unset because the CI runners (GitHub Actions) set
      31              :  * them, which would otherwise override the $HOME-based config root that
      32              :  * platform_config_dir() derives.
      33              :  */
      34              : 
      35              : #include "test_helpers.h"
      36              : 
      37              : #include "app/session_store.h"
      38              : #include "logger.h"
      39              : #include "mtproto_session.h"
      40              : 
      41              : #include <errno.h>
      42              : #include <fcntl.h>
      43              : #include <stdint.h>
      44              : #include <stdio.h>
      45              : #include <stdlib.h>
      46              : #include <string.h>
      47              : #include <sys/stat.h>
      48              : #include <sys/types.h>
      49              : #include <unistd.h>
      50              : 
      51              : /* ------------------------------------------------------------------ */
      52              : /* Schema constants (mirrored from session_store.c).                  */
      53              : /* ------------------------------------------------------------------ */
      54              : 
      55              : #define STORE_MAGIC_STR       "TGCS"
      56              : #define STORE_VERSION_V1      1
      57              : #define STORE_VERSION_V2      2
      58              : #define STORE_HEADER_SIZE     16
      59              : #define STORE_ENTRY_SIZE      276
      60              : /* v1 payload: magic(4) + ver(4) + dc_id(4) + server_salt(8) +
      61              :  *             session_id(8) + auth_key(256) = 284 bytes. */
      62              : #define STORE_V1_TOTAL_SIZE   284
      63              : 
      64              : /* ------------------------------------------------------------------ */
      65              : /* Helpers                                                            */
      66              : /* ------------------------------------------------------------------ */
      67              : 
      68              : /** Build a scratch HOME path keyed by test tag + pid. */
      69           12 : static void scratch_dir_for(const char *tag, char *out, size_t cap) {
      70           12 :     snprintf(out, cap, "/tmp/tg-cli-ft-sessmigr-%s-%d", tag, (int)getpid());
      71           12 : }
      72              : 
      73              : /** rm -rf best-effort. */
      74           24 : static void rm_rf(const char *path) {
      75              :     char cmd[4096];
      76           24 :     snprintf(cmd, sizeof(cmd), "rm -rf \"%s\"", path);
      77           24 :     int sysrc = system(cmd);
      78              :     (void)sysrc;
      79           24 : }
      80              : 
      81              : /** mkdir -p wrapper. */
      82           24 : static int mkdir_p(const char *path) {
      83              :     char cmd[4096];
      84           24 :     snprintf(cmd, sizeof(cmd), "mkdir -p \"%s\"", path);
      85           24 :     int sysrc = system(cmd);
      86           24 :     return sysrc == 0 ? 0 : -1;
      87              : }
      88              : 
      89              : /**
      90              :  * Redirect $HOME to a fresh scratch dir, unset XDG_* so the production
      91              :  * code's platform_config_dir() actually derives the config root from our
      92              :  * redirected $HOME, and point the logger at a per-test log file so the
      93              :  * assertions can pattern-match diagnostics.
      94              :  */
      95           12 : static void with_fresh_home(const char *tag,
      96              :                             char *out_home, size_t home_cap,
      97              :                             char *out_log,  size_t log_cap) {
      98           12 :     scratch_dir_for(tag, out_home, home_cap);
      99           12 :     rm_rf(out_home);
     100              : 
     101              :     char cfg_dir[600];
     102           12 :     snprintf(cfg_dir, sizeof(cfg_dir), "%s/.config/tg-cli", out_home);
     103           12 :     (void)mkdir_p(cfg_dir);
     104              : 
     105              :     char cache_dir[600];
     106           12 :     snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/tg-cli/logs", out_home);
     107           12 :     (void)mkdir_p(cache_dir);
     108              : 
     109           12 :     setenv("HOME", out_home, 1);
     110              :     /* CI runners (GitHub Actions) set these; they must be cleared so
     111              :      * platform_config_dir() / platform_cache_dir() fall back to $HOME. */
     112           12 :     unsetenv("XDG_CONFIG_HOME");
     113           12 :     unsetenv("XDG_CACHE_HOME");
     114              : 
     115           12 :     snprintf(out_log, log_cap, "%s/session.log", cache_dir);
     116           12 :     (void)unlink(out_log);
     117           12 :     (void)logger_init(out_log, LOG_DEBUG);
     118           12 : }
     119              : 
     120              : /** Build the canonical session.bin path under @p home. */
     121           12 : static void session_path_from_home(const char *home, char *out, size_t cap) {
     122           12 :     snprintf(out, cap, "%s/.config/tg-cli/session.bin", home);
     123           12 : }
     124              : 
     125              : /** Read a file into a heap buffer (caller frees). NUL-terminated for
     126              :  *  safe strstr() over binary files. */
     127           20 : static char *slurp(const char *path, size_t *size_out) {
     128           20 :     FILE *fp = fopen(path, "rb");
     129           20 :     if (!fp) return NULL;
     130           20 :     if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return NULL; }
     131           20 :     long sz = ftell(fp);
     132           20 :     if (sz < 0) { fclose(fp); return NULL; }
     133           20 :     if (fseek(fp, 0, SEEK_SET) != 0) { fclose(fp); return NULL; }
     134           20 :     char *buf = malloc((size_t)sz + 1);
     135           20 :     if (!buf) { fclose(fp); return NULL; }
     136           20 :     size_t n = fread(buf, 1, (size_t)sz, fp);
     137           20 :     fclose(fp);
     138           20 :     buf[n] = '\0';
     139           20 :     if (size_out) *size_out = n;
     140           20 :     return buf;
     141              : }
     142              : 
     143              : /** Overwrite @p path with @p buf. */
     144           12 : static int write_full(const char *path, const uint8_t *buf, size_t n) {
     145           12 :     int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
     146           12 :     if (fd == -1) return -1;
     147           12 :     ssize_t w = write(fd, buf, n);
     148           12 :     close(fd);
     149           12 :     return (w < 0 || (size_t)w != n) ? -1 : 0;
     150              : }
     151              : 
     152              : /**
     153              :  * Craft a valid legacy v1 session.bin payload into @p buf (>=284 bytes).
     154              :  *
     155              :  * Layout:
     156              :  *   offset 0  — "TGCS"            (4 B)
     157              :  *   offset 4  — version = 1       (int32 LE)
     158              :  *   offset 8  — dc_id             (int32 LE)
     159              :  *   offset 12 — server_salt       (uint64 LE)
     160              :  *   offset 20 — session_id        (uint64 LE)
     161              :  *   offset 28 — auth_key          (256 B, filled with @p key_byte)
     162              :  */
     163            8 : static size_t craft_v1_file(uint8_t *buf, size_t cap,
     164              :                             int32_t dc_id, uint64_t salt, uint64_t sess,
     165              :                             uint8_t key_byte) {
     166            8 :     if (cap < STORE_V1_TOTAL_SIZE) return 0;
     167            8 :     memset(buf, 0, STORE_V1_TOTAL_SIZE);
     168            8 :     memcpy(buf, STORE_MAGIC_STR, 4);
     169            8 :     int32_t v = STORE_VERSION_V1;
     170            8 :     memcpy(buf + 4,  &v,     4);
     171            8 :     memcpy(buf + 8,  &dc_id, 4);
     172            8 :     memcpy(buf + 12, &salt,  8);
     173            8 :     memcpy(buf + 20, &sess,  8);
     174            8 :     memset(buf + 28, key_byte, 256);
     175            8 :     return STORE_V1_TOTAL_SIZE;
     176              : }
     177              : 
     178              : /** Craft a fake future v3 file (same outer shape as v2 but version=3). */
     179            2 : static size_t craft_v3_file(uint8_t *buf, size_t cap, uint8_t key_byte) {
     180            2 :     size_t need = STORE_HEADER_SIZE + STORE_ENTRY_SIZE;
     181            2 :     if (cap < need) return 0;
     182            2 :     memset(buf, 0, need);
     183            2 :     memcpy(buf, STORE_MAGIC_STR, 4);
     184            2 :     int32_t v = 3;
     185            2 :     memcpy(buf + 4, &v, 4);
     186            2 :     int32_t home_dc = 2;
     187            2 :     memcpy(buf + 8, &home_dc, 4);
     188            2 :     uint32_t count = 1;
     189            2 :     memcpy(buf + 12, &count, 4);
     190            2 :     int32_t  dc_id       = 2;
     191            2 :     uint64_t server_salt = 0xDEADBEEFCAFEBABEULL;
     192            2 :     uint64_t session_id  = 0x0011223344556677ULL;
     193            2 :     memcpy(buf + STORE_HEADER_SIZE + 0,  &dc_id,       4);
     194            2 :     memcpy(buf + STORE_HEADER_SIZE + 4,  &server_salt, 8);
     195            2 :     memcpy(buf + STORE_HEADER_SIZE + 12, &session_id,  8);
     196            2 :     memset(buf + STORE_HEADER_SIZE + 20, key_byte, 256);
     197            2 :     return need;
     198              : }
     199              : 
     200              : /* ================================================================ */
     201              : /* Tests                                                            */
     202              : /* ================================================================ */
     203              : 
     204              : /**
     205              :  * 1. A hand-crafted valid v1 file loads into the v2 in-memory struct.
     206              :  *    The loader must populate every relevant field of MtProtoSession
     207              :  *    (auth_key, salt, session_id) and surface the recorded dc_id as
     208              :  *    the home DC.
     209              :  */
     210            2 : static void test_v1_file_loads_into_v2_in_memory(void) {
     211              :     char home[512], log[1024];
     212            2 :     with_fresh_home("v1load", home, sizeof(home), log, sizeof(log));
     213              : 
     214              :     char bin[1024];
     215            2 :     session_path_from_home(home, bin, sizeof(bin));
     216              : 
     217              :     uint8_t v1[STORE_V1_TOTAL_SIZE];
     218            2 :     uint64_t salt = 0x1122334455667788ULL;
     219            2 :     uint64_t sess = 0xAABBCCDDEEFF0011ULL;
     220            2 :     size_t n = craft_v1_file(v1, sizeof(v1),
     221              :                              /*dc_id=*/2, salt, sess, /*key_byte=*/0xA5);
     222            2 :     ASSERT(n == STORE_V1_TOTAL_SIZE, "crafted v1 file size correct");
     223            2 :     ASSERT(write_full(bin, v1, n) == 0, "v1 file written to scratch HOME");
     224              : 
     225              :     MtProtoSession s;
     226            2 :     mtproto_session_init(&s);
     227            2 :     int dc = 0;
     228            2 :     int rc = session_store_load(&s, &dc);
     229            2 :     ASSERT(rc == 0, "session_store_load accepts legacy v1 payload");
     230            2 :     ASSERT(dc == 2, "home DC reported as the v1 entry's dc_id");
     231            2 :     ASSERT(s.server_salt == salt, "server_salt carried across migration");
     232            2 :     ASSERT(s.session_id  == sess, "session_id carried across migration");
     233            2 :     ASSERT(s.has_auth_key == 1, "auth_key flagged as present post-migration");
     234              :     /* Spot-check auth_key contents (every byte should be 0xA5). */
     235            2 :     int all_a5 = 1;
     236          514 :     for (size_t i = 0; i < MTPROTO_AUTH_KEY_SIZE; i++) {
     237          512 :         if (s.auth_key[i] != 0xA5) { all_a5 = 0; break; }
     238              :     }
     239            2 :     ASSERT(all_a5, "auth_key bytes intact after v1→v2 load");
     240              : 
     241            2 :     logger_close();
     242            2 :     size_t sz = 0;
     243            2 :     char *buf = slurp(log, &sz);
     244            2 :     ASSERT(buf != NULL, "read session.log");
     245            2 :     ASSERT(strstr(buf, "migrated v1 file") != NULL,
     246              :            "log announces the v1→v2 migration explicitly");
     247            2 :     free(buf);
     248              : 
     249            2 :     rm_rf(home);
     250              : }
     251              : 
     252              : /**
     253              :  * 2. After the v1 load, the next save must rewrite session.bin in v2
     254              :  *    format: magic preserved, version byte stamped as 2, home_dc / count
     255              :  *    header filled in, and mode 0600 intact.
     256              :  */
     257            2 : static void test_v1_file_rewritten_as_v2_on_save(void) {
     258              :     char home[512], log[1024];
     259            2 :     with_fresh_home("v1rewrite", home, sizeof(home), log, sizeof(log));
     260              : 
     261              :     char bin[1024];
     262            2 :     session_path_from_home(home, bin, sizeof(bin));
     263              : 
     264              :     /* Plant a v1 file. */
     265              :     uint8_t v1[STORE_V1_TOTAL_SIZE];
     266            2 :     uint64_t salt = 0x5555666677778888ULL;
     267            2 :     uint64_t sess = 0x9999AAAABBBBCCCCULL;
     268            2 :     (void)craft_v1_file(v1, sizeof(v1), /*dc_id=*/3, salt, sess, 0x7E);
     269            2 :     ASSERT(write_full(bin, v1, sizeof(v1)) == 0, "v1 seed written");
     270              : 
     271              :     /* Load (migrates into v2 in memory). */
     272              :     MtProtoSession s;
     273            2 :     mtproto_session_init(&s);
     274            2 :     int dc = 0;
     275            2 :     ASSERT(session_store_load(&s, &dc) == 0, "v1 load succeeds");
     276            2 :     ASSERT(dc == 3, "home DC recorded from v1");
     277              : 
     278              :     /* Save: should rewrite on-disk file in v2 format. */
     279            2 :     ASSERT(session_store_save(&s, dc) == 0, "save rewrites in v2 format");
     280              : 
     281              :     /* Inspect the resulting file. */
     282            2 :     size_t sz = 0;
     283            2 :     uint8_t *after = (uint8_t *)slurp(bin, &sz);
     284            2 :     ASSERT(after != NULL, "read post-save session.bin");
     285            2 :     ASSERT(sz == (size_t)(STORE_HEADER_SIZE + STORE_ENTRY_SIZE),
     286              :            "post-save file has exactly one v2 entry body (276 B + 16 B header)");
     287            2 :     ASSERT(memcmp(after, STORE_MAGIC_STR, 4) == 0, "magic preserved");
     288              : 
     289            2 :     int32_t ver_on_disk = 0;
     290            2 :     memcpy(&ver_on_disk, after + 4, 4);
     291            2 :     ASSERT(ver_on_disk == STORE_VERSION_V2,
     292              :            "version byte bumped to 2 on save");
     293              : 
     294            2 :     int32_t home_on_disk = 0;
     295            2 :     memcpy(&home_on_disk, after + 8, 4);
     296            2 :     ASSERT(home_on_disk == 3, "home_dc_id preserved through migration");
     297              : 
     298            2 :     uint32_t count_on_disk = 0;
     299            2 :     memcpy(&count_on_disk, after + 12, 4);
     300            2 :     ASSERT(count_on_disk == 1, "entry count == 1 for single migrated DC");
     301              : 
     302            2 :     int32_t entry_dc = 0;
     303            2 :     memcpy(&entry_dc, after + STORE_HEADER_SIZE + 0, 4);
     304            2 :     ASSERT(entry_dc == 3, "entry[0].dc_id preserved");
     305              : 
     306            2 :     uint64_t entry_salt = 0;
     307            2 :     memcpy(&entry_salt, after + STORE_HEADER_SIZE + 4, 8);
     308            2 :     ASSERT(entry_salt == salt, "entry[0].server_salt preserved");
     309              : 
     310            2 :     uint64_t entry_sess = 0;
     311            2 :     memcpy(&entry_sess, after + STORE_HEADER_SIZE + 12, 8);
     312            2 :     ASSERT(entry_sess == sess, "entry[0].session_id preserved");
     313              : 
     314            2 :     free(after);
     315              : 
     316              :     struct stat st;
     317            2 :     ASSERT(stat(bin, &st) == 0, "stat post-save");
     318            2 :     ASSERT((st.st_mode & 0777) == 0600,
     319              :            "mode 0600 applied to migrated file");
     320              : 
     321              :     /* And the re-load from v2 must come back clean. */
     322              :     MtProtoSession s2;
     323            2 :     mtproto_session_init(&s2);
     324            2 :     int dc2 = 0;
     325            2 :     ASSERT(session_store_load(&s2, &dc2) == 0, "re-load from v2 succeeds");
     326            2 :     ASSERT(dc2 == 3, "home DC round-trips");
     327            2 :     ASSERT(s2.server_salt == salt, "salt round-trips");
     328            2 :     ASSERT(s2.session_id  == sess, "session id round-trips");
     329              : 
     330            2 :     logger_close();
     331            2 :     rm_rf(home);
     332              : }
     333              : 
     334              : /**
     335              :  * 3. Simulate a crash mid-save by making the config directory hostile
     336              :  *    to the atomic-rename path (plant a *directory* at session.bin.tmp
     337              :  *    so open(O_WRONLY|O_CREAT|O_TRUNC) fails with EISDIR).  The save
     338              :  *    must return non-zero *and* the original v1 file must still be
     339              :  *    byte-identical on disk so the next run can retry.
     340              :  */
     341            2 : static void test_crash_between_v1_load_and_v2_save_keeps_v1(void) {
     342              :     char home[512], log[1024];
     343            2 :     with_fresh_home("v1crash", home, sizeof(home), log, sizeof(log));
     344              : 
     345              :     char bin[1024];
     346            2 :     session_path_from_home(home, bin, sizeof(bin));
     347              :     char tmp[1024];
     348            2 :     snprintf(tmp, sizeof(tmp),
     349              :              "%s/.config/tg-cli/session.bin.tmp", home);
     350              : 
     351              :     /* Plant the v1 file. */
     352              :     uint8_t v1[STORE_V1_TOTAL_SIZE];
     353            2 :     uint64_t salt = 0xAAAA1111BBBB2222ULL;
     354            2 :     uint64_t sess = 0x3333CCCC4444DDDDULL;
     355            2 :     (void)craft_v1_file(v1, sizeof(v1), /*dc_id=*/4, salt, sess, 0x33);
     356            2 :     ASSERT(write_full(bin, v1, sizeof(v1)) == 0, "v1 seed written");
     357              : 
     358              :     /* Snapshot bytes on disk before attempting migration. */
     359            2 :     size_t before_sz = 0;
     360            2 :     char *before = slurp(bin, &before_sz);
     361            2 :     ASSERT(before != NULL, "snapshot v1 bytes pre-save");
     362            2 :     ASSERT(before_sz == STORE_V1_TOTAL_SIZE, "snapshot size matches v1");
     363              : 
     364              :     /* Perform the v1 load. */
     365              :     MtProtoSession s;
     366            2 :     mtproto_session_init(&s);
     367            2 :     int dc = 0;
     368            2 :     ASSERT(session_store_load(&s, &dc) == 0, "v1 load still succeeds");
     369            2 :     ASSERT(dc == 4, "home DC from v1");
     370              : 
     371              :     /* Now sabotage the atomic-rename path: turn session.bin.tmp into a
     372              :      * directory so the next open(..., O_WRONLY|O_TRUNC) fails. */
     373              :     char mkcmd[2048];
     374            2 :     snprintf(mkcmd, sizeof(mkcmd), "mkdir -p \"%s\"", tmp);
     375            2 :     int sysrc = system(mkcmd);
     376            2 :     ASSERT(sysrc == 0, "plant blocking directory at .tmp");
     377              : 
     378              :     /* Save must fail (cannot create its staging file). */
     379            2 :     int rc = session_store_save(&s, dc);
     380            2 :     ASSERT(rc != 0, "save fails when staging .tmp cannot be opened");
     381              : 
     382              :     /* The critical invariant: on-disk v1 bytes still intact. */
     383            2 :     size_t after_sz = 0;
     384            2 :     char *after = slurp(bin, &after_sz);
     385            2 :     ASSERT(after != NULL, "snapshot bytes post-failed-save");
     386            2 :     ASSERT(after_sz == before_sz,
     387              :            "failed-save leaves v1 file size unchanged");
     388            2 :     ASSERT(memcmp(before, after, before_sz) == 0,
     389              :            "failed-save leaves v1 file bytes unchanged (retry safe)");
     390            2 :     free(before);
     391            2 :     free(after);
     392              : 
     393              :     /* Re-run the migration (simulating a restart) — it must still work
     394              :      * once the blocker is cleared. */
     395              :     char rmcmd[2048];
     396            2 :     snprintf(rmcmd, sizeof(rmcmd), "rmdir \"%s\"", tmp);
     397            2 :     int rmrc = system(rmcmd);
     398            2 :     ASSERT(rmrc == 0, "remove blocking directory");
     399              : 
     400              :     MtProtoSession s2;
     401            2 :     mtproto_session_init(&s2);
     402            2 :     int dc2 = 0;
     403            2 :     ASSERT(session_store_load(&s2, &dc2) == 0,
     404              :            "v1 load retried successfully");
     405            2 :     ASSERT(session_store_save(&s2, dc2) == 0,
     406              :            "save succeeds on retry");
     407              : 
     408              :     /* Confirm file is now v2. */
     409            2 :     size_t final_sz = 0;
     410            2 :     uint8_t *final = (uint8_t *)slurp(bin, &final_sz);
     411            2 :     ASSERT(final != NULL, "read final session.bin");
     412            2 :     int32_t final_ver = 0;
     413            2 :     memcpy(&final_ver, final + 4, 4);
     414            2 :     ASSERT(final_ver == STORE_VERSION_V2,
     415              :            "file finally rewritten as v2 on retry");
     416            2 :     free(final);
     417              : 
     418            2 :     logger_close();
     419              : 
     420              :     /* Clean up any residual chmod drift under the scratch tree. */
     421              :     char cleanup_cmd[1024];
     422            2 :     snprintf(cleanup_cmd, sizeof(cleanup_cmd),
     423              :              "chmod -R u+w \"%s\" 2>/dev/null", home);
     424            2 :     int cleanup_rc = system(cleanup_cmd);
     425              :     (void)cleanup_rc;
     426            2 :     rm_rf(home);
     427              : }
     428              : 
     429              : /**
     430              :  * 4. A fake future v3 file must be refused without clobbering.
     431              :  *    session_store_load returns non-zero; the on-disk bytes are
     432              :  *    unchanged; the log carries the "unknown session version — upgrade
     433              :  *    client" diagnostic so the operator knows to upgrade rather than
     434              :  *    re-authenticate.
     435              :  */
     436            2 : static void test_future_v3_file_rejected_without_clobber(void) {
     437              :     char home[512], log[1024];
     438            2 :     with_fresh_home("v3", home, sizeof(home), log, sizeof(log));
     439              : 
     440              :     char bin[1024];
     441            2 :     session_path_from_home(home, bin, sizeof(bin));
     442              : 
     443              :     uint8_t v3[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     444            2 :     size_t n = craft_v3_file(v3, sizeof(v3), 0xF3);
     445            2 :     ASSERT(n > 0, "crafted v3 blob");
     446            2 :     ASSERT(write_full(bin, v3, n) == 0, "v3 file written");
     447              : 
     448              :     /* Snapshot before load. */
     449            2 :     size_t before_sz = 0;
     450            2 :     char *before = slurp(bin, &before_sz);
     451            2 :     ASSERT(before != NULL, "snapshot v3 bytes pre-load");
     452              : 
     453              :     MtProtoSession s;
     454            2 :     mtproto_session_init(&s);
     455            2 :     int dc = 0;
     456            2 :     int rc = session_store_load(&s, &dc);
     457            2 :     ASSERT(rc != 0, "load refuses an unknown-future v3 file");
     458              :     /* has_auth_key should remain cleared — the loader must not have
     459              :      * populated the struct from a version it cannot parse. */
     460            2 :     ASSERT(s.has_auth_key == 0,
     461              :            "in-memory session untouched by failed v3 load");
     462              : 
     463              :     /* On-disk bytes unchanged. */
     464            2 :     size_t after_sz = 0;
     465            2 :     char *after = slurp(bin, &after_sz);
     466            2 :     ASSERT(after != NULL, "snapshot post-failed-load");
     467            2 :     ASSERT(after_sz == before_sz,
     468              :            "load-only leaves v3 file size unchanged");
     469            2 :     ASSERT(memcmp(before, after, before_sz) == 0,
     470              :            "load-only leaves v3 file bytes unchanged (no clobber)");
     471            2 :     free(before);
     472            2 :     free(after);
     473              : 
     474            2 :     logger_close();
     475            2 :     size_t sz = 0;
     476            2 :     char *buf = slurp(log, &sz);
     477            2 :     ASSERT(buf != NULL, "read session.log");
     478            2 :     ASSERT(strstr(buf, "unknown session version") != NULL,
     479              :            "log mentions 'unknown session version'");
     480            2 :     ASSERT(strstr(buf, "upgrade client") != NULL,
     481              :            "log mentions 'upgrade client' remediation hint");
     482            2 :     ASSERT(strstr(buf, "migrated v1") == NULL,
     483              :            "no spurious v1-migration log for a v3 file");
     484            2 :     free(buf);
     485              : 
     486            2 :     rm_rf(home);
     487              : }
     488              : 
     489              : /**
     490              :  * 5. A v1 header with a truncated body (version byte says 1 but fewer
     491              :  *    than 284 bytes are on disk) is rejected with a distinct diagnostic.
     492              :  *    This guards the v1-migration code against reading off the end of
     493              :  *    the buffer and ensures a partially-written legacy file is not
     494              :  *    silently treated as valid.
     495              :  */
     496            2 : static void test_truncated_v1_file_refuses_load(void) {
     497              :     char home[512], log[1024];
     498            2 :     with_fresh_home("v1trunc", home, sizeof(home), log, sizeof(log));
     499              : 
     500              :     char bin[1024];
     501            2 :     session_path_from_home(home, bin, sizeof(bin));
     502              : 
     503              :     /* Build a complete v1 payload then keep only the first 100 bytes —
     504              :      * past the 16-byte header so the magic + version checks both pass,
     505              :      * but well short of the 284-byte full payload. */
     506              :     uint8_t v1[STORE_V1_TOTAL_SIZE];
     507            2 :     (void)craft_v1_file(v1, sizeof(v1), /*dc_id=*/2,
     508              :                         0x1111ULL, 0x2222ULL, 0x5C);
     509            2 :     ASSERT(write_full(bin, v1, 100) == 0, "short v1 file written");
     510              : 
     511              :     MtProtoSession s;
     512            2 :     mtproto_session_init(&s);
     513            2 :     int dc = 0;
     514            2 :     int rc = session_store_load(&s, &dc);
     515            2 :     ASSERT(rc != 0, "load refuses a truncated-v1 file");
     516              : 
     517            2 :     logger_close();
     518            2 :     size_t sz = 0;
     519            2 :     char *buf = slurp(log, &sz);
     520            2 :     ASSERT(buf != NULL, "read session.log");
     521            2 :     ASSERT(strstr(buf, "truncated v1 payload") != NULL,
     522              :            "log mentions 'truncated v1 payload'");
     523            2 :     free(buf);
     524              : 
     525            2 :     rm_rf(home);
     526              : }
     527              : 
     528              : /**
     529              :  * 6. A version-zero file (neither v1 nor v2 nor future) must take the
     530              :  *    plain "unsupported version" branch — distinct from the "upgrade
     531              :  *    client" diagnostic reserved for forward-only incompatibility.
     532              :  *    This keeps the legacy corruption-recovery contract (US-25) intact.
     533              :  */
     534            2 : static void test_unsupported_low_version_refuses_load(void) {
     535              :     char home[512], log[1024];
     536            2 :     with_fresh_home("v0", home, sizeof(home), log, sizeof(log));
     537              : 
     538              :     char bin[1024];
     539            2 :     session_path_from_home(home, bin, sizeof(bin));
     540              : 
     541              :     uint8_t pkt[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     542            2 :     memset(pkt, 0, sizeof(pkt));
     543            2 :     memcpy(pkt, STORE_MAGIC_STR, 4);
     544            2 :     int32_t v = 0;                       /* explicitly non-v1, non-v2, non-future */
     545            2 :     memcpy(pkt + 4, &v, 4);
     546            2 :     int32_t home_dc = 2;
     547            2 :     memcpy(pkt + 8, &home_dc, 4);
     548            2 :     uint32_t count = 0;
     549            2 :     memcpy(pkt + 12, &count, 4);
     550            2 :     ASSERT(write_full(bin, pkt, sizeof(pkt)) == 0,
     551              :            "v0 file written");
     552              : 
     553              :     MtProtoSession s;
     554            2 :     mtproto_session_init(&s);
     555            2 :     int dc = 0;
     556            2 :     int rc = session_store_load(&s, &dc);
     557            2 :     ASSERT(rc != 0, "load refuses a v0 (below-current) file");
     558              : 
     559            2 :     logger_close();
     560            2 :     size_t sz = 0;
     561            2 :     char *buf = slurp(log, &sz);
     562            2 :     ASSERT(buf != NULL, "read session.log");
     563            2 :     ASSERT(strstr(buf, "unsupported version 0") != NULL,
     564              :            "log mentions 'unsupported version 0'");
     565              :     /* The low-version branch must NOT emit the upgrade hint — that hint
     566              :      * is specifically for forward-incompatible (future) versions. */
     567            2 :     ASSERT(strstr(buf, "upgrade client") == NULL,
     568              :            "no 'upgrade client' hint for sub-current versions");
     569            2 :     free(buf);
     570              : 
     571            2 :     rm_rf(home);
     572              : }
     573              : 
     574              : /* ================================================================ */
     575              : /* Suite entry point                                                */
     576              : /* ================================================================ */
     577              : 
     578            2 : void run_session_migration_tests(void) {
     579            2 :     RUN_TEST(test_v1_file_loads_into_v2_in_memory);
     580            2 :     RUN_TEST(test_v1_file_rewritten_as_v2_on_save);
     581            2 :     RUN_TEST(test_crash_between_v1_load_and_v2_save_keeps_v1);
     582            2 :     RUN_TEST(test_future_v3_file_rejected_without_clobber);
     583            2 :     RUN_TEST(test_truncated_v1_file_refuses_load);
     584            2 :     RUN_TEST(test_unsupported_low_version_refuses_load);
     585            2 : }
        

Generated by: LCOV version 2.0-1