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 : }
|