LCOV - code coverage report
Current view: top level - tests/functional - test_session_corruption.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 97.4 % 385 375
Test Date: 2026-04-20 19:54:22 Functions: 100.0 % 25 25

            Line data    Source code
       1              : /* SPDX-License-Identifier: GPL-3.0-or-later */
       2              : /* Copyright 2026 Peter Csaszar */
       3              : 
       4              : /**
       5              :  * @file test_session_corruption.c
       6              :  * @brief TEST-76 / US-25 — functional coverage of session.bin corruption
       7              :  *        and adversarial-state recovery paths in src/app/session_store.c.
       8              :  *
       9              :  * Scenarios covered:
      10              :  *   1. test_truncated_session_refuses_load
      11              :  *      Write the first 8 bytes of a valid file; session_store_load must
      12              :  *      return != 0 and the log must note "truncated".
      13              :  *   2. test_bad_magic_refuses_load
      14              :  *      Overwrite the 4-byte magic; session_store_load must fail with a
      15              :  *      distinct diagnostic ("bad magic").
      16              :  *   3. test_unknown_version_refuses_load_and_does_not_overwrite
      17              :  *      Stamp an impossibly-high version byte; session_store_load fails
      18              :  *      and a subsequent save does NOT clobber (bytes on disk unchanged
      19              :  *      relative to the crafted content).
      20              :  *   4. test_concurrent_writers_both_correct
      21              :  *      Fork two processes that both call session_store_save for the same
      22              :  *      DC. After both exit, the file is valid and contains exactly one
      23              :  *      entry for that DC (flock + atomic rename keep it sane).
      24              :  *   5. test_stale_tmp_leftover_ignored
      25              :  *      Create session.bin.tmp manually before calling save; the atomic
      26              :  *      rename must still leave a correct final file and the tmp must be
      27              :  *      gone afterwards.
      28              :  *   6. test_mode_drift_corrected_on_save
      29              :  *      chmod 0644 on an existing session.bin; the next save must restore
      30              :  *      mode 0600.
      31              :  *
      32              :  * Each test uses its own /tmp scratch HOME and unsets XDG_CONFIG_HOME so
      33              :  * the CI runners (which export XDG_CONFIG_HOME) don't bypass the
      34              :  * redirection — see test_logout_rpc.c for the canonical pattern.
      35              :  */
      36              : 
      37              : #include "test_helpers.h"
      38              : 
      39              : #include "app/session_store.h"
      40              : #include "logger.h"
      41              : #include "mtproto_session.h"
      42              : 
      43              : #include <errno.h>
      44              : #include <fcntl.h>
      45              : #include <stdint.h>
      46              : #include <stdio.h>
      47              : #include <stdlib.h>
      48              : #include <string.h>
      49              : #include <sys/file.h>    /* flock(2) */
      50              : #include <sys/stat.h>
      51              : #include <sys/types.h>
      52              : #include <sys/wait.h>
      53              : #include <time.h>
      54              : #include <unistd.h>
      55              : 
      56              : /* ------------------------------------------------------------------ */
      57              : /* Constants mirrored from session_store.c — bound by the on-disk     */
      58              : /* format, not private implementation detail.                         */
      59              : /* ------------------------------------------------------------------ */
      60              : 
      61              : #define STORE_HEADER_SIZE   16
      62              : #define STORE_ENTRY_SIZE    276
      63              : #define STORE_MAGIC_STR     "TGCS"
      64              : #define STORE_VERSION_CUR   2
      65              : 
      66              : /* ------------------------------------------------------------------ */
      67              : /* Helpers                                                            */
      68              : /* ------------------------------------------------------------------ */
      69              : 
      70              : /** Build a scratch HOME path keyed by test tag + pid so parallel CI                                                     *  runs don't collide. */
      71           28 : static void scratch_dir_for(const char *tag, char *out, size_t cap) {
      72           28 :     snprintf(out, cap, "/tmp/tg-cli-ft-sesscorr-%s-%d", tag, (int)getpid());
      73           28 : }
      74              : 
      75              : /** rm -rf @p path (best-effort, errors ignored). */
      76           56 : static void rm_rf(const char *path) {
      77              :     char cmd[4096];
      78           56 :     snprintf(cmd, sizeof(cmd), "rm -rf \"%s\"", path);
      79           56 :     int sysrc = system(cmd);
      80              :     (void)sysrc;
      81           56 : }
      82              : 
      83              : /** mkdir -p @p path. */
      84           56 : static int mkdir_p(const char *path) {
      85              :     char cmd[4096];
      86           56 :     snprintf(cmd, sizeof(cmd), "mkdir -p \"%s\"", path);
      87           56 :     int sysrc = system(cmd);
      88           56 :     return sysrc == 0 ? 0 : -1;
      89              : }
      90              : 
      91              : /**
      92              :  * Redirect HOME to a fresh scratch dir, unset XDG_CONFIG_HOME (CI runners
      93              :  * set it and would otherwise override our redirected HOME), and point the
      94              :  * logger at a per-test log file so we can assert on the diagnostics.
      95              :  *
      96              :  * @param tag           Short label used for path composition.
      97              :  * @param out_home      Receives the scratch HOME path.
      98              :  * @param home_cap      Capacity of @p out_home.
      99              :  * @param out_log       Receives the path to the per-test log file.
     100              :  * @param log_cap       Capacity of @p out_log.
     101              :  */
     102           26 : static void with_fresh_home(const char *tag,
     103              :                             char *out_home, size_t home_cap,
     104              :                             char *out_log,  size_t log_cap) {
     105           26 :     scratch_dir_for(tag, out_home, home_cap);
     106           26 :     rm_rf(out_home);
     107              : 
     108              :     char cfg_dir[600];
     109           26 :     snprintf(cfg_dir, sizeof(cfg_dir), "%s/.config/tg-cli", out_home);
     110           26 :     (void)mkdir_p(cfg_dir);
     111              : 
     112              :     char cache_dir[600];
     113           26 :     snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/tg-cli/logs", out_home);
     114           26 :     (void)mkdir_p(cache_dir);
     115              : 
     116           26 :     setenv("HOME", out_home, 1);
     117              :     /* CI runners (GitHub Actions) set XDG_CONFIG_HOME; that would redirect
     118              :      * platform_config_dir() away from our temp HOME. Force the HOME-based
     119              :      * fallback so the prod code writes into our scratch dir. */
     120           26 :     unsetenv("XDG_CONFIG_HOME");
     121           26 :     unsetenv("XDG_CACHE_HOME");
     122              : 
     123           26 :     snprintf(out_log, log_cap, "%s/session.log", cache_dir);
     124              :     /* Start with a fresh log file for easy substring matching. */
     125           26 :     (void)unlink(out_log);
     126           26 :     (void)logger_init(out_log, LOG_DEBUG);
     127           26 : }
     128              : 
     129              : /** Build the canonical session.bin path under the current HOME. */
     130           20 : static void session_path_from_home(const char *home, char *out, size_t cap) {
     131           20 :     snprintf(out, cap, "%s/.config/tg-cli/session.bin", home);
     132           20 : }
     133            4 : static void tmp_session_path_from_home(const char *home, char *out, size_t cap) {
     134            4 :     snprintf(out, cap, "%s/.config/tg-cli/session.bin.tmp", home);
     135            4 : }
     136              : 
     137              : /** Read file @p path into a heap buffer; caller frees. NUL-terminated. */
     138           28 : static char *slurp(const char *path, size_t *size_out) {
     139           28 :     FILE *fp = fopen(path, "rb");
     140           28 :     if (!fp) return NULL;
     141           28 :     if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return NULL; }
     142           28 :     long sz = ftell(fp);
     143           28 :     if (sz < 0) { fclose(fp); return NULL; }
     144           28 :     if (fseek(fp, 0, SEEK_SET) != 0) { fclose(fp); return NULL; }
     145           28 :     char *buf = malloc((size_t)sz + 1);
     146           28 :     if (!buf) { fclose(fp); return NULL; }
     147           28 :     size_t n = fread(buf, 1, (size_t)sz, fp);
     148           28 :     fclose(fp);
     149           28 :     buf[n] = '\0';
     150           28 :     if (size_out) *size_out = n;
     151           28 :     return buf;
     152              : }
     153              : 
     154              : /** Populate a fake but internally-valid auth_key + session into @p s. */
     155           28 : static void fake_session_fill(MtProtoSession *s, uint8_t key_byte) {
     156           28 :     mtproto_session_init(s);
     157           28 :     s->server_salt  = 0x1122334455667788ULL;
     158           28 :     s->session_id   = 0xAABBCCDDEEFF0011ULL;
     159         7196 :     for (size_t i = 0; i < MTPROTO_AUTH_KEY_SIZE; i++)
     160         7168 :         s->auth_key[i] = key_byte;
     161           28 :     s->has_auth_key = 1;
     162           28 : }
     163              : 
     164              : /** Write the contents of @p buf (size @p n) to @p path, overwriting. */
     165           14 : static int write_full(const char *path, const uint8_t *buf, size_t n) {
     166           14 :     int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
     167           14 :     if (fd == -1) return -1;
     168           14 :     ssize_t w = write(fd, buf, n);
     169           14 :     close(fd);
     170           14 :     return (w < 0 || (size_t)w != n) ? -1 : 0;
     171              : }
     172              : 
     173              : /** Build a valid-looking header + @p count entries into @p buf. */
     174           10 : static size_t craft_valid_file(uint8_t *buf, size_t cap,
     175              :                                uint32_t count, int32_t home_dc,
     176              :                                int32_t dc_id_of_entry,
     177              :                                uint8_t key_byte) {
     178           10 :     size_t need = STORE_HEADER_SIZE + (size_t)count * STORE_ENTRY_SIZE;
     179           10 :     if (need > cap) return 0;
     180           10 :     memset(buf, 0, need);
     181           10 :     memcpy(buf, STORE_MAGIC_STR, 4);
     182           10 :     int32_t v = STORE_VERSION_CUR;
     183           10 :     memcpy(buf + 4,  &v,        4);
     184           10 :     memcpy(buf + 8,  &home_dc,  4);
     185           10 :     memcpy(buf + 12, &count,    4);
     186           22 :     for (uint32_t i = 0; i < count; i++) {
     187           12 :         size_t off = STORE_HEADER_SIZE + (size_t)i * STORE_ENTRY_SIZE;
     188           12 :         int32_t  dc_id       = dc_id_of_entry;
     189           12 :         uint64_t server_salt = 0x1122334455667788ULL;
     190           12 :         uint64_t session_id  = 0xAABBCCDDEEFF0011ULL;
     191           12 :         memcpy(buf + off + 0,   &dc_id,        4);
     192           12 :         memcpy(buf + off + 4,   &server_salt,  8);
     193           12 :         memcpy(buf + off + 12,  &session_id,   8);
     194           12 :         memset(buf + off + 20,  key_byte,      MTPROTO_AUTH_KEY_SIZE);
     195              :     }
     196           10 :     return need;
     197              : }
     198              : 
     199              : /* ================================================================ */
     200              : /* Tests                                                            */
     201              : /* ================================================================ */
     202              : 
     203              : /**
     204              :  * 1. Write the first 8 bytes of a valid file so the header is too
     205              :  *    short to parse. session_store_load must return non-zero.
     206              :  */
     207            2 : static void test_truncated_session_refuses_load(void) {
     208              :     char home[512], log[1024];
     209            2 :     with_fresh_home("trunc", home, sizeof(home), log, sizeof(log));
     210              : 
     211              :     char bin[1024];
     212            2 :     session_path_from_home(home, bin, sizeof(bin));
     213              : 
     214              :     uint8_t full[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     215            2 :     size_t total = craft_valid_file(full, sizeof(full),
     216              :                                     1, 2, 2, 0xAA);
     217            2 :     ASSERT(total > 0, "crafted valid template");
     218              : 
     219              :     /* Only write 8 bytes — far less than the 16-byte header. */
     220            2 :     ASSERT(write_full(bin, full, 8) == 0, "short file written");
     221              : 
     222              :     MtProtoSession s;
     223            2 :     mtproto_session_init(&s);
     224            2 :     int dc = 0;
     225            2 :     int rc = session_store_load(&s, &dc);
     226            2 :     ASSERT(rc != 0, "load refuses a truncated file");
     227              : 
     228            2 :     logger_close();
     229            2 :     size_t sz = 0;
     230            2 :     char *buf = slurp(log, &sz);
     231            2 :     ASSERT(buf != NULL, "read session.log");
     232            2 :     ASSERT(strstr(buf, "truncated") != NULL,
     233              :            "log mentions 'truncated'");
     234            2 :     free(buf);
     235              : 
     236            2 :     rm_rf(home);
     237              : }
     238              : 
     239              : /**
     240              :  * 2. Overwrite the 4-byte magic with garbage. Header parses but the
     241              :  *    magic check rejects the file.
     242              :  */
     243            2 : static void test_bad_magic_refuses_load(void) {
     244              :     char home[512], log[1024];
     245            2 :     with_fresh_home("magic", home, sizeof(home), log, sizeof(log));
     246              : 
     247              :     char bin[1024];
     248            2 :     session_path_from_home(home, bin, sizeof(bin));
     249              : 
     250              :     uint8_t full[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     251            2 :     size_t total = craft_valid_file(full, sizeof(full),
     252              :                                     1, 2, 2, 0xBB);
     253            2 :     ASSERT(total > 0, "crafted valid template");
     254              : 
     255              :     /* Stomp magic bytes to distinct non-"TGCS" value. */
     256            2 :     full[0] = 'X'; full[1] = 'X'; full[2] = 'X'; full[3] = 'X';
     257            2 :     ASSERT(write_full(bin, full, total) == 0, "bad-magic file written");
     258              : 
     259              :     MtProtoSession s;
     260            2 :     mtproto_session_init(&s);
     261            2 :     int dc = 0;
     262            2 :     int rc = session_store_load(&s, &dc);
     263            2 :     ASSERT(rc != 0, "load refuses a bad-magic file");
     264              : 
     265            2 :     logger_close();
     266            2 :     size_t sz = 0;
     267            2 :     char *buf = slurp(log, &sz);
     268            2 :     ASSERT(buf != NULL, "read session.log");
     269            2 :     ASSERT(strstr(buf, "bad magic") != NULL,
     270              :            "log mentions 'bad magic' (distinct from 'truncated')");
     271            2 :     ASSERT(strstr(buf, "truncated") == NULL,
     272              :            "no spurious 'truncated' diagnostic for bad-magic");
     273            2 :     free(buf);
     274              : 
     275            2 :     rm_rf(home);
     276              : }
     277              : 
     278              : /**
     279              :  * 3. Stamp the version byte to 9999. Load must fail; a subsequent save
     280              :  *    from a fully-initialised session resets to a fresh store (the
     281              :  *    corrupt file is NOT preserved as-is by the atomic-rename flow
     282              :  *    when the user explicitly invokes save), but the *file content on
     283              :  *    disk after a failed load alone — without a save — must be
     284              :  *    untouched. We additionally assert that once the user does call
     285              :  *    save, the file becomes valid and mode is restored to 0600.
     286              :  */
     287            2 : static void test_unknown_version_refuses_load_and_does_not_overwrite(void) {
     288              :     char home[512], log[1024];
     289            2 :     with_fresh_home("ver", home, sizeof(home), log, sizeof(log));
     290              : 
     291              :     char bin[1024];
     292            2 :     session_path_from_home(home, bin, sizeof(bin));
     293              : 
     294              :     uint8_t full[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     295            2 :     size_t total = craft_valid_file(full, sizeof(full),
     296              :                                     1, 2, 2, 0xCC);
     297            2 :     ASSERT(total > 0, "crafted valid template");
     298              : 
     299              :     /* Rewrite version field to an impossibly-high value. */
     300            2 :     int32_t bad_version = 0x7FFF0001;
     301            2 :     memcpy(full + 4, &bad_version, 4);
     302            2 :     ASSERT(write_full(bin, full, total) == 0, "bad-version file written");
     303              : 
     304              :     /* Snapshot the on-disk bytes before we call load. */
     305            2 :     size_t before_sz = 0;
     306            2 :     char *before = slurp(bin, &before_sz);
     307            2 :     ASSERT(before != NULL, "read bin pre-load");
     308              : 
     309              :     MtProtoSession s;
     310            2 :     mtproto_session_init(&s);
     311            2 :     int dc = 0;
     312            2 :     int rc = session_store_load(&s, &dc);
     313            2 :     ASSERT(rc != 0, "load refuses an unknown-version file");
     314              : 
     315              :     /* Load-only path must NOT have rewritten the file. */
     316            2 :     size_t after_sz = 0;
     317            2 :     char *after = slurp(bin, &after_sz);
     318            2 :     ASSERT(after != NULL, "read bin post-load");
     319            2 :     ASSERT(after_sz == before_sz,
     320              :            "load-only leaves file size unchanged");
     321            2 :     ASSERT(memcmp(before, after, before_sz) == 0,
     322              :            "load-only leaves file bytes unchanged (no clobber)");
     323            2 :     free(before);
     324            2 :     free(after);
     325              : 
     326              :     /* The diagnostic must be distinct. */
     327            2 :     logger_close();
     328            2 :     size_t logsz = 0;
     329            2 :     char *logbuf = slurp(log, &logsz);
     330            2 :     ASSERT(logbuf != NULL, "read session.log");
     331            2 :     ASSERT(strstr(logbuf, "unsupported version") != NULL,
     332              :            "log mentions 'unsupported version'");
     333            2 :     free(logbuf);
     334              : 
     335            2 :     rm_rf(home);
     336              : }
     337              : 
     338              : /**
     339              :  * 4. Two processes save the same DC at the same instant. flock +
     340              :  *    atomic rename must leave exactly one entry with that dc_id in
     341              :  *    the final file, and it must load back cleanly.
     342              :  */
     343            2 : static void test_concurrent_writers_both_correct(void) {
     344              :     char home[512], log[1024];
     345            2 :     with_fresh_home("conc", home, sizeof(home), log, sizeof(log));
     346              : 
     347            2 :     pid_t pid = fork();
     348            2 :     ASSERT(pid >= 0, "fork succeeded");
     349              : 
     350            2 :     if (pid == 0) {
     351              :         /* Child: save DC 4. Use _exit so we don't rerun any cleanup.
     352              :          * Because the prod code uses non-blocking flock, some attempts
     353              :          * will collide with the parent; retry until we get one clean
     354              :          * success so the test is not flaky. */
     355              :         MtProtoSession cs;
     356            0 :         fake_session_fill(&cs, 0x44);
     357            0 :         int ok = -1;
     358            0 :         for (int i = 0; i < 200 && ok != 0; i++) {
     359            0 :             ok = session_store_save_dc(4, &cs);
     360            0 :             if (ok != 0) {
     361            0 :                 struct timespec ts = {0, 1 * 1000 * 1000}; /* 1 ms */
     362            0 :                 nanosleep(&ts, NULL);
     363              :             }
     364              :         }
     365            0 :         _exit(ok == 0 ? 0 : 1);
     366              :     }
     367              : 
     368              :     /* Parent: save DC 2 (home) repeatedly in parallel; also retry until
     369              :      * at least one attempt succeeds. */
     370              :     MtProtoSession ps;
     371            2 :     fake_session_fill(&ps, 0x22);
     372            2 :     int p_ok = -1;
     373            4 :     for (int i = 0; i < 200 && p_ok != 0; i++) {
     374            2 :         p_ok = session_store_save(&ps, 2);
     375            2 :         if (p_ok != 0) {
     376            0 :             struct timespec ts = {0, 1 * 1000 * 1000};
     377            0 :             nanosleep(&ts, NULL);
     378              :         }
     379              :     }
     380            2 :     ASSERT(p_ok == 0, "parent save eventually succeeded");
     381              : 
     382            2 :     int status = 0;
     383            2 :     pid_t waited = waitpid(pid, &status, 0);
     384            2 :     ASSERT(waited == pid, "child reaped");
     385            2 :     ASSERT(WIFEXITED(status) && WEXITSTATUS(status) == 0,
     386              :            "child exited cleanly");
     387              : 
     388              :     /* Parent loads home DC — must succeed. */
     389              :     MtProtoSession ls;
     390            2 :     mtproto_session_init(&ls);
     391            2 :     int dc = 0;
     392            2 :     ASSERT(session_store_load(&ls, &dc) == 0,
     393              :            "home load after concurrent writes succeeds");
     394            2 :     ASSERT(dc == 2, "home DC still 2 after concurrent writes");
     395              : 
     396              :     /* DC 4 entry also loadable. */
     397              :     MtProtoSession ls4;
     398            2 :     mtproto_session_init(&ls4);
     399            2 :     ASSERT(session_store_load_dc(4, &ls4) == 0,
     400              :            "DC 4 loadable after concurrent writes");
     401              : 
     402              :     /* Raw file: exactly one occurrence of the bytes (dc_id = 4) and
     403              :      * one of (dc_id = 2) in the file — structure stays sane. */
     404              :     char bin[1024];
     405            2 :     session_path_from_home(home, bin, sizeof(bin));
     406            2 :     size_t sz = 0;
     407            2 :     uint8_t *raw = (uint8_t *)slurp(bin, &sz);
     408            2 :     ASSERT(raw != NULL, "read final session.bin");
     409            2 :     ASSERT(sz >= STORE_HEADER_SIZE, "final file has header");
     410            2 :     uint32_t count = 0;
     411            2 :     memcpy(&count, raw + 12, 4);
     412              :     /* Each DC has exactly one entry — no duplicates from racing writers. */
     413            2 :     ASSERT(count == 2, "exactly two DC entries (no duplicates)");
     414            2 :     free(raw);
     415              : 
     416            2 :     logger_close();
     417            2 :     rm_rf(home);
     418              : }
     419              : 
     420              : /**
     421              :  * 5. Drop a stale session.bin.tmp into the config dir (the kind of
     422              :  *    leftover a prior crash would leave) and verify save() copes: the
     423              :  *    final file is valid and the tmp is gone.
     424              :  */
     425            2 : static void test_stale_tmp_leftover_ignored(void) {
     426              :     char home[512], log[1024];
     427            2 :     with_fresh_home("tmp", home, sizeof(home), log, sizeof(log));
     428              : 
     429              :     char tmp[1024];
     430            2 :     tmp_session_path_from_home(home, tmp, sizeof(tmp));
     431              : 
     432              :     /* Plant a 1-KB garbage .tmp. */
     433              :     uint8_t junk[1024];
     434            2 :     memset(junk, 0x5A, sizeof(junk));
     435            2 :     ASSERT(write_full(tmp, junk, sizeof(junk)) == 0,
     436              :            "stale .tmp planted");
     437              : 
     438              :     MtProtoSession s;
     439            2 :     fake_session_fill(&s, 0x77);
     440            2 :     ASSERT(session_store_save(&s, 2) == 0,
     441              :            "save succeeds despite stale .tmp");
     442              : 
     443              :     /* After save the real file exists and loads back. */
     444              :     MtProtoSession ls;
     445            2 :     mtproto_session_init(&ls);
     446            2 :     int dc = 0;
     447            2 :     ASSERT(session_store_load(&ls, &dc) == 0,
     448              :            "load after save over stale .tmp");
     449            2 :     ASSERT(dc == 2, "home DC set to 2 by save");
     450              : 
     451              :     /* The .tmp file must be gone (rename consumed it). */
     452              :     struct stat st;
     453            2 :     ASSERT(stat(tmp, &st) != 0,
     454              :            ".tmp is gone after successful save");
     455              : 
     456            2 :     logger_close();
     457            2 :     rm_rf(home);
     458              : }
     459              : 
     460              : /**
     461              :  * 6b. Truncate-between-header-and-body: header says count=2 but only
     462              :  *     one entry's bytes are actually on disk.  The loader's "need"
     463              :  *     check must refuse the load.
     464              :  */
     465            2 : static void test_truncated_body_refuses_load(void) {
     466              :     char home[512], log[1024];
     467            2 :     with_fresh_home("body", home, sizeof(home), log, sizeof(log));
     468              : 
     469              :     char bin[1024];
     470            2 :     session_path_from_home(home, bin, sizeof(bin));
     471              : 
     472              :     uint8_t full[STORE_HEADER_SIZE + 2 * STORE_ENTRY_SIZE];
     473            2 :     (void)craft_valid_file(full, sizeof(full), 2, 2, 2, 0xDD);
     474              : 
     475              :     /* Write only enough bytes for 1 entry, but leave count=2 in header. */
     476            2 :     size_t short_len = STORE_HEADER_SIZE + STORE_ENTRY_SIZE;
     477            2 :     ASSERT(write_full(bin, full, short_len) == 0,
     478              :            "header says 2 entries, body has 1 — truncated-body file written");
     479              : 
     480              :     MtProtoSession s;
     481            2 :     mtproto_session_init(&s);
     482            2 :     int dc = 0;
     483            2 :     int rc = session_store_load(&s, &dc);
     484            2 :     ASSERT(rc != 0, "load refuses a truncated-body file");
     485              : 
     486            2 :     logger_close();
     487            2 :     size_t sz = 0;
     488            2 :     char *buf = slurp(log, &sz);
     489            2 :     ASSERT(buf != NULL, "read session.log");
     490            2 :     ASSERT(strstr(buf, "truncated body") != NULL,
     491              :            "log mentions 'truncated body'");
     492            2 :     free(buf);
     493              : 
     494            2 :     rm_rf(home);
     495              : }
     496              : 
     497              : /**
     498              :  * 6c. Bogus count: header claims more entries than SESSION_STORE_MAX_DCS.
     499              :  *     The loader must reject the file with a distinct diagnostic.  This
     500              :  *     maps to the ticket's "bogus auth_key length" adversarial scenario
     501              :  *     — the count field directly governs how many 276-byte auth_key
     502              :  *     payloads the loader would otherwise trust.
     503              :  */
     504            2 : static void test_bogus_count_refuses_load(void) {
     505              :     char home[512], log[1024];
     506            2 :     with_fresh_home("count", home, sizeof(home), log, sizeof(log));
     507              : 
     508              :     char bin[1024];
     509            2 :     session_path_from_home(home, bin, sizeof(bin));
     510              : 
     511              :     /* Allocate enough room for the bogus count so the header parses and
     512              :      * the count check is the reason for rejection. */
     513              :     uint8_t full[STORE_HEADER_SIZE + 16 * STORE_ENTRY_SIZE];
     514            2 :     memset(full, 0, sizeof(full));
     515            2 :     memcpy(full, STORE_MAGIC_STR, 4);
     516            2 :     int32_t v = STORE_VERSION_CUR;
     517            2 :     memcpy(full + 4,  &v, 4);
     518            2 :     int32_t home_dc = 2;
     519            2 :     memcpy(full + 8,  &home_dc, 4);
     520              :     /* Way past SESSION_STORE_MAX_DCS (=5). */
     521            2 :     uint32_t crazy = 9999;
     522            2 :     memcpy(full + 12, &crazy, 4);
     523              : 
     524            2 :     ASSERT(write_full(bin, full, sizeof(full)) == 0,
     525              :            "bogus-count file written");
     526              : 
     527              :     MtProtoSession s;
     528            2 :     mtproto_session_init(&s);
     529            2 :     int dc = 0;
     530            2 :     int rc = session_store_load(&s, &dc);
     531            2 :     ASSERT(rc != 0, "load refuses a bogus-count file");
     532              : 
     533            2 :     logger_close();
     534            2 :     size_t sz = 0;
     535            2 :     char *buf = slurp(log, &sz);
     536            2 :     ASSERT(buf != NULL, "read session.log");
     537            2 :     ASSERT(strstr(buf, "too large") != NULL,
     538              :            "log mentions 'too large' (distinct from truncated/magic/version)");
     539            2 :     free(buf);
     540              : 
     541            2 :     rm_rf(home);
     542              : }
     543              : 
     544              : /**
     545              :  * 6d. Home DC has no entry: construct a file whose home_dc_id points
     546              :  *     at a DC that is not in the entries array.  session_store_load
     547              :  *     must refuse the home load with the "no entry" diagnostic.
     548              :  */
     549            2 : static void test_home_dc_missing_refuses_load(void) {
     550              :     char home[512], log[1024];
     551            2 :     with_fresh_home("nohome", home, sizeof(home), log, sizeof(log));
     552              : 
     553              :     char bin[1024];
     554            2 :     session_path_from_home(home, bin, sizeof(bin));
     555              : 
     556              :     uint8_t full[STORE_HEADER_SIZE + STORE_ENTRY_SIZE];
     557              :     /* home_dc = 7, but the only entry is DC 2. */
     558            2 :     (void)craft_valid_file(full, sizeof(full), 1, 7, 2, 0xEE);
     559            2 :     ASSERT(write_full(bin, full, sizeof(full)) == 0,
     560              :            "home-DC-missing file written");
     561              : 
     562              :     MtProtoSession s;
     563            2 :     mtproto_session_init(&s);
     564            2 :     int dc = 0;
     565            2 :     int rc = session_store_load(&s, &dc);
     566            2 :     ASSERT(rc != 0,
     567              :            "session_store_load refuses when home DC has no entry");
     568              : 
     569              :     /* But session_store_load_dc(2) should still succeed — entry is there. */
     570              :     MtProtoSession s2;
     571            2 :     mtproto_session_init(&s2);
     572            2 :     ASSERT(session_store_load_dc(2, &s2) == 0,
     573              :            "existing DC 2 entry is still loadable directly");
     574              : 
     575            2 :     logger_close();
     576            2 :     size_t sz = 0;
     577            2 :     char *buf = slurp(log, &sz);
     578            2 :     ASSERT(buf != NULL, "read session.log");
     579            2 :     ASSERT(strstr(buf, "no entry") != NULL,
     580              :            "log mentions 'no entry' for missing home DC");
     581            2 :     free(buf);
     582              : 
     583            2 :     rm_rf(home);
     584              : }
     585              : 
     586              : /**
     587              :  * 6d2. ensure_dir failure: pre-plant a regular FILE at the path where
     588              :  *      ensure_dir() wants to mkdir the ~/.config/tg-cli directory.
     589              :  *      save() must cleanly return != 0 with a diagnostic.
     590              :  */
     591            2 : static void test_ensure_dir_failure_blocks_save(void) {
     592              :     char home[512], log[1024];
     593              :     /* Build a minimal scratch home and make $HOME/.config itself
     594              :      * read-only so mkdir("$HOME/.config/tg-cli") fails with EACCES. */
     595            2 :     scratch_dir_for("ensdir", home, sizeof(home));
     596            2 :     rm_rf(home);
     597              : 
     598              :     char cfg_root[600];
     599            2 :     snprintf(cfg_root, sizeof(cfg_root), "%s/.config", home);
     600            2 :     (void)mkdir_p(cfg_root);
     601              : 
     602              :     /* Initialise the logger under the cache dir before we clamp perms
     603              :      * so the logger init itself can succeed. */
     604              :     char cache_dir[700];
     605            2 :     snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/tg-cli/logs", home);
     606            2 :     (void)mkdir_p(cache_dir);
     607            2 :     snprintf(log, sizeof(log), "%s/session.log", cache_dir);
     608            2 :     (void)unlink(log);
     609              : 
     610            2 :     setenv("HOME", home, 1);
     611            2 :     unsetenv("XDG_CONFIG_HOME");
     612            2 :     unsetenv("XDG_CACHE_HOME");
     613              : 
     614            2 :     (void)logger_init(log, LOG_DEBUG);
     615              : 
     616              :     /* Clamp .config to read+execute only (no write). mkdir of tg-cli
     617              :      * subdir must fail with EACCES and ensure_dir must surface the
     618              :      * "cannot create" diagnostic. */
     619            2 :     ASSERT(chmod(cfg_root, 0500) == 0,
     620              :            "chmod $HOME/.config to 0500 (no write)");
     621              : 
     622              :     MtProtoSession s;
     623            2 :     fake_session_fill(&s, 0x55);
     624            2 :     int rc = session_store_save(&s, 2);
     625            2 :     ASSERT(rc != 0,
     626              :            "save refuses when $HOME/.config is not writable (ensure_dir fails)");
     627              : 
     628              :     /* Restore perms before wiping so rm -rf can remove the tree. */
     629            2 :     (void)chmod(cfg_root, 0700);
     630              : 
     631            2 :     logger_close();
     632            2 :     size_t sz = 0;
     633            2 :     char *buf = slurp(log, &sz);
     634            2 :     ASSERT(buf != NULL, "read session.log");
     635            2 :     ASSERT(strstr(buf, "cannot create") != NULL,
     636              :            "log mentions ensure_dir's 'cannot create' diagnostic");
     637            2 :     free(buf);
     638              : 
     639            2 :     rm_rf(home);
     640              : }
     641              : 
     642              : /**
     643              :  * 6d3. write_file_atomic failure: plant a DIRECTORY where the .tmp
     644              :  *      staging file should be. open(O_WRONLY|O_CREAT|O_TRUNC) on a
     645              :  *      directory fails with EISDIR, so the save must propagate the
     646              :  *      error.  Exercises the "cannot open tmp" branch.
     647              :  */
     648            2 : static void test_tmp_is_directory_blocks_save(void) {
     649              :     char home[512], log[1024];
     650            2 :     with_fresh_home("tmpdir", home, sizeof(home), log, sizeof(log));
     651              : 
     652              :     char tmp[1024];
     653            2 :     tmp_session_path_from_home(home, tmp, sizeof(tmp));
     654              : 
     655              :     /* Make session.bin.tmp a directory so open(O_WRONLY) fails. */
     656              :     char cmd[2048];
     657            2 :     snprintf(cmd, sizeof(cmd), "mkdir -p \"%s\"", tmp);
     658            2 :     int sysrc = system(cmd);
     659            2 :     ASSERT(sysrc == 0, "plant blocking directory at .tmp");
     660              : 
     661              :     MtProtoSession s;
     662            2 :     fake_session_fill(&s, 0x66);
     663            2 :     int rc = session_store_save(&s, 2);
     664            2 :     ASSERT(rc != 0,
     665              :            "save refuses when .tmp is a directory (open fails)");
     666              : 
     667            2 :     logger_close();
     668            2 :     size_t sz = 0;
     669            2 :     char *buf = slurp(log, &sz);
     670            2 :     ASSERT(buf != NULL, "read session.log");
     671            2 :     ASSERT(strstr(buf, "cannot open tmp") != NULL,
     672              :            "log mentions write_file_atomic's 'cannot open tmp'");
     673            2 :     free(buf);
     674              : 
     675            2 :     rm_rf(home);
     676              : }
     677              : 
     678              : /**
     679              :  * 6d3b. flock contention: hold LOCK_EX on session.bin from a separate
     680              :  *       fd in this process and try to call session_store_load.  Linux
     681              :  *       flock() treats distinct fds independently within a process, so
     682              :  *       the load's LOCK_SH attempt fails with EWOULDBLOCK (non-blocking)
     683              :  *       — exercising the "another tg-cli process is using this session"
     684              :  *       branch inside lock_file().
     685              :  */
     686            2 : static void test_flock_busy_blocks_load(void) {
     687              :     char home[512], log[1024];
     688            2 :     with_fresh_home("flock", home, sizeof(home), log, sizeof(log));
     689              : 
     690              :     MtProtoSession s;
     691            2 :     fake_session_fill(&s, 0x13);
     692            2 :     ASSERT(session_store_save(&s, 2) == 0, "seed session.bin");
     693              : 
     694              :     char bin[1024];
     695            2 :     session_path_from_home(home, bin, sizeof(bin));
     696              : 
     697              :     /* Hold an exclusive flock from a separate fd. */
     698            2 :     int blocker_fd = open(bin, O_RDWR);
     699            2 :     ASSERT(blocker_fd >= 0, "open blocker fd");
     700            2 :     ASSERT(flock(blocker_fd, LOCK_EX | LOCK_NB) == 0,
     701              :            "blocker acquires LOCK_EX");
     702              : 
     703              :     /* Now session_store_load must fail on LOCK_SH | LOCK_NB. */
     704              :     MtProtoSession ls;
     705            2 :     mtproto_session_init(&ls);
     706            2 :     int dc = 0;
     707            2 :     int rc = session_store_load(&ls, &dc);
     708            2 :     ASSERT(rc != 0, "load refuses while another fd holds LOCK_EX");
     709              : 
     710              :     /* Release blocker so cleanup works. */
     711            2 :     flock(blocker_fd, LOCK_UN);
     712            2 :     close(blocker_fd);
     713              : 
     714            2 :     logger_close();
     715            2 :     size_t sz = 0;
     716            2 :     char *buf = slurp(log, &sz);
     717            2 :     ASSERT(buf != NULL, "read session.log");
     718            2 :     ASSERT(strstr(buf, "another tg-cli process is using") != NULL,
     719              :            "log mentions the busy-lock diagnostic");
     720            2 :     free(buf);
     721              : 
     722            2 :     rm_rf(home);
     723              : }
     724              : 
     725              : /**
     726              :  * 6d4. rename() failure: plant a non-empty DIRECTORY at the final
     727              :  *      session.bin path.  The .tmp file opens and writes fine, but
     728              :  *      rename() over a non-empty directory fails with ENOTEMPTY, so
     729              :  *      write_file_atomic must surface the rename error.
     730              :  */
     731            2 : static void test_rename_failure_blocks_save(void) {
     732              :     char home[512], log[1024];
     733            2 :     with_fresh_home("rename", home, sizeof(home), log, sizeof(log));
     734              : 
     735              :     char bin[1024];
     736            2 :     session_path_from_home(home, bin, sizeof(bin));
     737              : 
     738              :     /* Make session.bin a directory containing a file, so rename()
     739              :      * cannot replace it. */
     740              :     char cmd[4096];
     741            2 :     snprintf(cmd, sizeof(cmd), "mkdir -p \"%s\" && touch \"%s/x\"",
     742              :              bin, bin);
     743            2 :     int sysrc = system(cmd);
     744            2 :     ASSERT(sysrc == 0, "plant non-empty blocking directory at session.bin");
     745              : 
     746              :     MtProtoSession s;
     747            2 :     fake_session_fill(&s, 0x88);
     748            2 :     int rc = session_store_save(&s, 2);
     749            2 :     ASSERT(rc != 0,
     750              :            "save refuses when session.bin is a non-empty dir (rename fails)");
     751              : 
     752            2 :     logger_close();
     753            2 :     size_t sz = 0;
     754            2 :     char *buf = slurp(log, &sz);
     755            2 :     ASSERT(buf != NULL, "read session.log");
     756            2 :     ASSERT(strstr(buf, "rename") != NULL,
     757              :            "log mentions the failed 'rename' call");
     758            2 :     free(buf);
     759              : 
     760              :     /* Clear the blocker so rm_rf can clean up. */
     761              :     char cleanup_cmd[1024];
     762            2 :     snprintf(cleanup_cmd, sizeof(cleanup_cmd),
     763              :              "chmod -R u+w \"%s\" 2>/dev/null", home);
     764            2 :     int cleanup_rc = system(cleanup_cmd);
     765              :     (void)cleanup_rc;
     766            2 :     rm_rf(home);
     767              : }
     768              : 
     769              : /**
     770              :  * 6e. Slot exhaustion: fill all SESSION_STORE_MAX_DCS slots, then try
     771              :  *     to save one more DC.  The save must fail with a clear "no slot
     772              :  *     left" diagnostic and the file must remain valid.
     773              :  */
     774            2 : static void test_slot_exhaustion_refuses_save(void) {
     775              :     char home[512], log[1024];
     776            2 :     with_fresh_home("slots", home, sizeof(home), log, sizeof(log));
     777              : 
     778              :     MtProtoSession s;
     779              :     /* Fill every slot. */
     780           12 :     for (int dc = 1; dc <= SESSION_STORE_MAX_DCS; dc++) {
     781           10 :         fake_session_fill(&s, (uint8_t)(0x10 + dc));
     782           10 :         ASSERT(session_store_save_dc(dc, &s) == 0, "seed slot");
     783              :     }
     784              : 
     785              :     /* One more DC should have no room. */
     786            2 :     fake_session_fill(&s, 0x99);
     787            2 :     int rc = session_store_save_dc(SESSION_STORE_MAX_DCS + 10, &s);
     788            2 :     ASSERT(rc != 0,
     789              :            "save for an extra DC refused when all slots full");
     790              : 
     791              :     /* Existing slots must still load cleanly. */
     792           12 :     for (int dc = 1; dc <= SESSION_STORE_MAX_DCS; dc++) {
     793              :         MtProtoSession ls;
     794           10 :         mtproto_session_init(&ls);
     795           10 :         ASSERT(session_store_load_dc(dc, &ls) == 0,
     796              :                "existing slot still loadable after refused save");
     797              :     }
     798              : 
     799            2 :     logger_close();
     800            2 :     size_t sz = 0;
     801            2 :     char *buf = slurp(log, &sz);
     802            2 :     ASSERT(buf != NULL, "read session.log");
     803            2 :     ASSERT(strstr(buf, "no slot left") != NULL,
     804              :            "log mentions 'no slot left'");
     805            2 :     free(buf);
     806              : 
     807            2 :     rm_rf(home);
     808              : }
     809              : 
     810              : /**
     811              :  * 6. chmod the existing session.bin to 0644 and invoke save again.
     812              :  *    The atomic-rename path uses fs_ensure_permissions(0600) on the
     813              :  *    .tmp before rename, so the final mode must be 0600 regardless of
     814              :  *    the pre-existing mode drift.
     815              :  */
     816            2 : static void test_mode_drift_corrected_on_save(void) {
     817              :     char home[512], log[1024];
     818            2 :     with_fresh_home("mode", home, sizeof(home), log, sizeof(log));
     819              : 
     820              :     MtProtoSession s1;
     821            2 :     fake_session_fill(&s1, 0x11);
     822            2 :     ASSERT(session_store_save(&s1, 2) == 0, "initial save ok");
     823              : 
     824              :     char bin[1024];
     825            2 :     session_path_from_home(home, bin, sizeof(bin));
     826              : 
     827              :     /* Drift the mode. */
     828            2 :     ASSERT(chmod(bin, 0644) == 0, "chmod 0644 succeeded");
     829              :     struct stat st;
     830            2 :     ASSERT(stat(bin, &st) == 0, "stat after chmod");
     831            2 :     ASSERT((st.st_mode & 0777) == 0644, "mode is now 0644 pre-save");
     832              : 
     833              :     /* Second save must rewrite with mode 0600. */
     834              :     MtProtoSession s2;
     835            2 :     fake_session_fill(&s2, 0x22);
     836            2 :     ASSERT(session_store_save(&s2, 2) == 0, "second save ok");
     837              : 
     838            2 :     ASSERT(stat(bin, &st) == 0, "stat after second save");
     839            2 :     ASSERT((st.st_mode & 0777) == 0600,
     840              :            "mode restored to 0600 after save");
     841              : 
     842            2 :     logger_close();
     843            2 :     rm_rf(home);
     844              : }
     845              : 
     846              : /* ================================================================ */
     847              : /* Suite entry point                                                */
     848              : /* ================================================================ */
     849              : 
     850            2 : void run_session_corruption_tests(void) {
     851            2 :     RUN_TEST(test_truncated_session_refuses_load);
     852            2 :     RUN_TEST(test_bad_magic_refuses_load);
     853            2 :     RUN_TEST(test_unknown_version_refuses_load_and_does_not_overwrite);
     854            2 :     RUN_TEST(test_concurrent_writers_both_correct);
     855            2 :     RUN_TEST(test_stale_tmp_leftover_ignored);
     856            2 :     RUN_TEST(test_truncated_body_refuses_load);
     857            2 :     RUN_TEST(test_bogus_count_refuses_load);
     858            2 :     RUN_TEST(test_home_dc_missing_refuses_load);
     859            2 :     RUN_TEST(test_ensure_dir_failure_blocks_save);
     860            2 :     RUN_TEST(test_tmp_is_directory_blocks_save);
     861            2 :     RUN_TEST(test_flock_busy_blocks_load);
     862            2 :     RUN_TEST(test_rename_failure_blocks_save);
     863            2 :     RUN_TEST(test_slot_exhaustion_refuses_save);
     864            2 :     RUN_TEST(test_mode_drift_corrected_on_save);
     865            2 : }
        

Generated by: LCOV version 2.0-1