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