Files
cproof/tests/unittests/test_database_stress.c
Jabber Developer 3f36c303c2
All checks were successful
CI Code / Check spelling (push) Successful in 21s
CI Code / Check coding style (push) Successful in 31s
CI Code / Code Coverage (push) Successful in 2m21s
CI Code / Linux (ubuntu) (push) Successful in 4m30s
CI Code / Linux (debian) (push) Successful in 6m43s
CI Code / Linux (arch) (push) Successful in 10m8s
feat(history): flat-file backend with bidirectional SQLite migration
A flat-file alternative to the SQLite chatlog backend with runtime
switching, full migration tooling, integrity verification, and a
synthetic load harness. SQLite remains the default; both backends share
one dispatch layer (db_backend_t vtable) so callers don't change.

Storage layout
- Per-contact append-only `flatlog/<account>/<contact>/history.log`
  under XDG_DATA_HOME, one line per message
- Single-line file header with embedded format-version marker
  (FLATFILE_FORMAT_VERSION); reader warns on missing or mismatched
  marker, writer and checker stay in sync via preprocessor
  stringification
- Deterministic key=value metadata (`id`, `aid`, `corrects`, `to`,
  `to_res`, `read`) plus escaped body \u2014 `\|`, `\]`, `\\`, `\n`, `\r`
  literals prevent log injection
- Sparse byte-offset index (FF_INDEX_STEP=500) per contact for
  O(log n) time-range lookups; rebuilt on inode / size / mtime
  change, extended in-place when the file just grew
- Per-contact GHashTable caches for archive_id presence and
  stanza_id \u2192 from_jid mapping (O(1) MAM dedup, O(1) LMC sender
  validation)

Hardening
- Path-traversal protection: JID directory name normalisation
  (`@` \u2192 `_at_`, slashes and `..` rejected at construction); every
  per-contact path is anchored under the account's flatlog/
  directory and validated before open
- Symlink-attack protection: every fopen / open uses O_NOFOLLOW; on
  ELOOP the operation aborts with an error rather than following
- Filesystem permissions: log files created with mode 0600,
  directories with mode 0700; both enforced at creation, verified
  on each open and reported on drift by `/history verify`
- Atomic crash-safe export: write to a temp file via mkstemp (mode
  0600, random suffix, no name collisions between concurrent
  exports), fsync, then rename \u2014 partial state never replaces the
  live file
- Concurrency: advisory flock(LOCK_EX) held for the duration of
  every write, including append from live messages and full rewrite
  from export, so two profanity processes can't interleave bytes
  on the same log
- DoS / abuse guards:
    * FF_MAX_LINE_LEN = 10 MB \u2014 lines longer than this are rejected
      at read with a warning; the parser will not allocate
      unbounded memory for a single record
    * FF_MAX_LMC_DEPTH = 100 \u2014 `corrects:` chain walk stops at this
      depth and emits a warning, preventing a malicious correction
      cycle from spinning the apply pass
    * FF_VERSION_SCAN_MAX = 16 \u2014 header version probe never reads
      past 16 leading comment lines, even on garbage input
    * Empty / inverted byte-range early-return in page-up read path
      so a malformed time filter cannot cause an unbounded scan
    * Zero-entry index guard so a file whose every line failed to
      parse cannot cause a NULL deref on later page-up
- LMC sender validation: an incoming correction whose sender does
  not match the original message's sender is rejected at write
  time and surfaced via cons_show_error; a cycle in the apply pass
  is broken via a visited-set
- jid_create_from_bare_and_resource treats NULL, empty string, and
  the literal "(null)" as no resource and returns a bare jid;
  similar normalisation for barejid eliminates the legacy
  "user@host/(null)" artefact that leaked into stored fulljids
  whenever g_strdup_printf("%s", NULL) ran inside create_fulljid

Commands
- `/history switch sqlite|flatfile` \u2014 runtime backend swap, closes
  the old backend and opens the new one without reconnecting
- `/history export [<jid>]` \u2014 SQLite -> flat-file, merging with any
  existing flatlog (dedup keyed on a SHA-256 hash mixing stanza_id,
  timestamp, from_jid, body \u2014 robust against id reuse by older
  clients)
- `/history import [<jid>]` \u2014 flat-file -> SQLite, same merge
  semantics, runs inside a single SQLite transaction with rollback
  on per-contact failure
- `/history verify [<jid>]` \u2014 integrity check; emits a structured
  list of issues (ERROR / WARNING / INFO) per file:
    * file-level: missing log, wrong permissions (\u2260 0600), UTF-8
      BOM present, CRLF line endings, empty file
    * line-level: invalid UTF-8 (with byte offset), embedded
      control characters, unparsable lines, timestamps out of
      order, duplicate `id:` and `aid:` (tracked separately so a
      stanza/archive id collision isn't double-reported)
    * cross-line: broken `corrects:` references whose target id is
      not present in the file
- `/history backend` \u2014 show currently active backend
- Active backend indicator `[sqlite]` / `[flatfile]` in the status
  bar next to the JID
- Roster-JID autocomplete for verify / export / import
- export and import open a SQLite handle on demand when the
  flatfile backend is currently active, so migration works
  regardless of which backend is live

Tests
- Unit: database_export (parser round-trip, escape/unescape, dedup
  key stability, JID normalisation), database_stress (14 cases
  exercising rapid writes, large messages, deep LMC chains, MAM
  dedup, concurrent contacts)
- Functional: history persistence across reconnects, export /
  import round-trip with content equality, MUC migration,
  timestamp normalisation across timezones
- Bench harness P1\u2013P5 (synthetic load: bulk insert, time-range
  read, page-up scroll, MAM ingest, mixed workload) and failure
  modes F1\u2013F17 (page-up cursor and forward-iteration symmetry,
  oversized lines, MAM dedup, LMC depth and cycles, BOM/CRLF,
  missing log, empty file, mtime+inode flip, broken corrects, etc.)
- All bench tests integrate with the existing make targets and
  emit CSV rows for baseline comparison

Author: jabber.developer2 <jabber.developer2@jabber.space>
Reviewed-by: jabber.developer <jabber.developer@jabber.space>
2026-05-05 19:26:07 +00:00

1138 lines
34 KiB
C

// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of CProof.
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
/*
* test_database_stress.c
*
* Stress tests for flat-file database I/O: rapid-fire writes, MUC messages
* with many participants, large message bodies, index/cache correctness,
* export/import merge under load, and deep LMC correction chains.
*/
#include "prof_cmocka.h"
#include <glib.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/stat.h>
#include "config.h"
#include "database_flatfile.h"
/* ================================================================
* Helpers
* ================================================================ */
/* Create a temporary directory under /tmp and return its path.
* Caller must g_free(). */
static char*
_make_tmpdir(void)
{
char tmpl[] = "/tmp/profstress_XXXXXX";
char* dir = mkdtemp(tmpl);
assert_non_null(dir);
return g_strdup(dir);
}
/* Write N lines to a file and return the path.
* Each line gets a unique timestamp, sender from the pool, and body.
* Caller must g_free() the path. */
static char*
_write_n_lines(const char* dir, int n, const char* type,
const char** senders, int n_senders,
const char* body_prefix, int body_size)
{
char* path = g_strdup_printf("%s/history.log", dir);
FILE* fp = fopen(path, "w");
assert_non_null(fp);
fprintf(fp, "%s", FLATFILE_HEADER);
/* Generate body of requested size */
char* body = NULL;
if (body_size > 0) {
body = g_malloc(body_size + 1);
int prefix_len = body_prefix ? (int)strlen(body_prefix) : 0;
if (prefix_len > 0 && prefix_len < body_size) {
memcpy(body, body_prefix, prefix_len);
} else {
prefix_len = 0;
}
/* Fill remaining with printable chars */
for (int i = prefix_len; i < body_size; i++) {
body[i] = 'A' + (i % 26);
}
body[body_size] = '\0';
}
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < n; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
const char* sender = senders[i % n_senders];
char* sid = g_strdup_printf("stress-sid-%d", i);
char* aid = g_strdup_printf("stress-aid-%d", i);
const char* msg = body ? body : body_prefix;
if (!msg) {
msg = g_strdup_printf("Stress message #%d from %s", i, sender);
ff_write_line(fp, ts_str, type, "none",
sid, aid, NULL,
sender, "resource",
NULL, NULL, -1,
msg);
g_free((char*)msg);
} else {
ff_write_line(fp, ts_str, type, "none",
sid, aid, NULL,
sender, "resource",
NULL, NULL, -1,
msg);
}
g_free(sid);
g_free(aid);
}
g_date_time_unref(base);
g_free(body);
fclose(fp);
return path;
}
/* Remove directory and its contents */
static void
_cleanup_dir(const char* dir)
{
auto_gchar gchar* cmd = g_strdup_printf("rm -rf '%s'", dir);
int ret = system(cmd);
(void)ret;
}
/* Current time in milliseconds */
static long long
_now_ms(void)
{
struct timeval tv;
gettimeofday(&tv, NULL);
return (long long)tv.tv_sec * 1000 + tv.tv_usec / 1000;
}
/* ================================================================
* High-throughput write + parse
* ================================================================ */
void
test_stress_rapid_write_parse_1000(void** state)
{
char tmppath[] = "/tmp/profstress_rw_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 3, 1, 0, 0, 0);
long long t0 = _now_ms();
for (int i = 0; i < 1000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32];
snprintf(sid, sizeof(sid), "sid-%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, NULL, NULL,
"alice@example.com", "phone",
"bob@example.com", NULL, -1,
"Quick test message number N");
}
fclose(fp);
long long t_write = _now_ms() - t0;
/* Now parse all lines back */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int parsed = 0;
t0 = _now_ms();
while (1) {
gboolean truncated = FALSE;
char* buf = ff_readline(fp, &truncated);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
ff_parsed_line_free(pl);
}
}
fclose(fp);
unlink(tmppath);
long long t_parse = _now_ms() - t0;
g_date_time_unref(base);
assert_int_equal(parsed, 1000);
/* Sanity: 1000 writes/parses shouldn't take more than 5 seconds */
assert_true(t_write < 5000);
assert_true(t_parse < 5000);
}
void
test_stress_rapid_write_parse_10000(void** state)
{
char tmppath[] = "/tmp/profstress_rw10k_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 3, 1, 0, 0, 0);
for (int i = 0; i < 10000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32], aid[32];
snprintf(sid, sizeof(sid), "sid10k-%d", i);
snprintf(aid, sizeof(aid), "aid10k-%d", i);
ff_write_line(fp, ts_str, "chat", "omemo",
sid, aid, NULL,
"user@jabber.org", "laptop",
"peer@jabber.org", "mobile", -1,
"Stress test message with metadata");
}
fclose(fp);
g_date_time_unref(base);
/* Parse all back */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
ff_parsed_line_free(pl);
}
}
fclose(fp);
unlink(tmppath);
assert_int_equal(parsed, 10000);
}
/* ================================================================
* MUC (chat room) messages
* ================================================================ */
void
test_stress_muc_many_participants(void** state)
{
/* 50 participants, 20 messages each = 1000 messages */
const int n_participants = 50;
const int msgs_per_participant = 20;
const int total = n_participants * msgs_per_participant;
char tmppath[] = "/tmp/profstress_muc_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 6, 15, 14, 0, 0);
for (int i = 0; i < total; i++) {
int participant_idx = i % n_participants;
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char from_jid[128];
snprintf(from_jid, sizeof(from_jid), "room@conference.example.com");
char from_resource[64];
snprintf(from_resource, sizeof(from_resource), "participant_%d", participant_idx);
char sid[48];
snprintf(sid, sizeof(sid), "muc-sid-%d", i);
char body[128];
snprintf(body, sizeof(body), "Message from participant %d, iteration %d",
participant_idx, i / n_participants);
ff_write_line(fp, ts_str, "muc", "none",
sid, NULL, NULL,
from_jid, from_resource,
NULL, NULL, -1,
body);
}
fclose(fp);
g_date_time_unref(base);
/* Parse and verify all MUC messages */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int parsed = 0;
int muc_count = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
if (g_strcmp0(pl->type, "muc") == 0)
muc_count++;
/* Verify resource is participant_N */
assert_non_null(pl->from_resource);
assert_true(g_str_has_prefix(pl->from_resource, "participant_"));
ff_parsed_line_free(pl);
}
}
fclose(fp);
unlink(tmppath);
assert_int_equal(parsed, total);
assert_int_equal(muc_count, total);
}
void
test_stress_muc_rapid_messages(void** state)
{
/* 5000 messages in a MUC from 10 users, 1 per second */
const int n_users = 10;
const int total = 5000;
char tmppath[] = "/tmp/profstress_mucrapid_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < total; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char from_res[32];
snprintf(from_res, sizeof(from_res), "user%d", i % n_users);
char sid[48];
snprintf(sid, sizeof(sid), "rapid-muc-%d", i);
ff_write_line(fp, ts_str, "muc", "omemo",
sid, sid, NULL,
"devroom@muc.jabber.org", from_res,
NULL, NULL, -1,
"rapid fire MUC message");
}
fclose(fp);
g_date_time_unref(base);
/* Parse back */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] != '#' && buf[0] != '\0') {
ff_parsed_line_t* pl = ff_parse_line(buf);
if (pl) {
parsed++;
ff_parsed_line_free(pl);
}
}
free(buf);
}
fclose(fp);
unlink(tmppath);
assert_int_equal(parsed, total);
}
/* ================================================================
* Large messages
* ================================================================ */
void
test_stress_large_message_1mb(void** state)
{
/* Generate a 1 MB message body */
const int body_size = 1024 * 1024;
char* body = g_malloc(body_size + 1);
for (int i = 0; i < body_size; i++) {
body[i] = 'A' + (i % 26);
}
body[body_size] = '\0';
char tmppath[] = "/tmp/profstress_1mb_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
ff_write_line(fp, "2026-01-01T00:00:00+00:00", "chat", "none",
"big-msg-1", NULL, NULL,
"alice@example.com", "desktop",
"bob@example.com", NULL, -1,
body);
fclose(fp);
/* Parse back */
fp = fopen(tmppath, "r");
assert_non_null(fp);
char* buf = ff_readline(fp, NULL);
fclose(fp);
unlink(tmppath);
assert_non_null(buf);
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
assert_non_null(pl);
assert_non_null(pl->message);
assert_int_equal((int)strlen(pl->message), body_size);
/* Spot-check content */
assert_int_equal(pl->message[0], 'A');
assert_int_equal(pl->message[25], 'Z');
assert_int_equal(pl->message[26], 'A');
assert_string_equal(pl->from_jid, "alice@example.com");
ff_parsed_line_free(pl);
g_free(body);
}
void
test_stress_large_message_body_with_special_chars(void** state)
{
/* 100 KB body with newlines, backslashes, pipes, brackets */
const int body_size = 100 * 1024;
GString* gs = g_string_sized_new(body_size);
for (int i = 0; i < body_size / 10; i++) {
g_string_append(gs, "Line\\n|]\r\n");
}
char tmppath[] = "/tmp/profstress_special_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
ff_write_line(fp, "2026-06-01T12:00:00+00:00", "chat", "pgp",
"special-1", "special-aid-1", NULL,
"eve@evil.org", "laptop",
"target@good.org", NULL, -1,
gs->str);
fclose(fp);
/* Parse back */
fp = fopen(tmppath, "r");
assert_non_null(fp);
char* buf = ff_readline(fp, NULL);
fclose(fp);
unlink(tmppath);
assert_non_null(buf);
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
assert_non_null(pl);
assert_non_null(pl->message);
/* The body should survive the escape→write→read→unescape roundtrip */
assert_string_equal(pl->message, gs->str);
assert_string_equal(pl->enc, "pgp");
ff_parsed_line_free(pl);
g_string_free(gs, TRUE);
}
void
test_stress_many_large_messages_100(void** state)
{
/* 100 messages, each 50 KB */
const int n_msgs = 100;
const int body_size = 50 * 1024;
char* body = g_malloc(body_size + 1);
for (int i = 0; i < body_size; i++)
body[i] = '0' + (i % 10);
body[body_size] = '\0';
char tmppath[] = "/tmp/profstress_100big_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < n_msgs; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i * 60);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32];
snprintf(sid, sizeof(sid), "big-%d", i);
ff_write_line(fp, ts_str, "chat", "omemo",
sid, NULL, NULL,
"sender@example.com", "res",
"recv@example.com", NULL, -1,
body);
}
fclose(fp);
g_date_time_unref(base);
/* Parse all, verify body integrity */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
assert_int_equal((int)strlen(pl->message), body_size);
parsed++;
ff_parsed_line_free(pl);
}
}
fclose(fp);
unlink(tmppath);
assert_int_equal(parsed, n_msgs);
g_free(body);
}
/* ================================================================
* Index: write 10000 lines, parse them all back (pure I/O stress)
* (ff_state_new/ensure_fresh require full database_flatfile.c linking;
* those are covered by the functional tests instead.)
* ================================================================ */
void
test_stress_index_build_10000_lines(void** state)
{
auto_gchar gchar* dir = _make_tmpdir();
const char* senders[] = { "a@x.org", "b@x.org", "c@x.org" };
auto_gchar gchar* path = _write_n_lines(dir, 10000, "chat", senders, 3, "msg", 0);
/* Parse all 10000 lines back */
FILE* fp = fopen(path, "r");
assert_non_null(fp);
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
ff_parsed_line_free(pl);
}
}
fclose(fp);
assert_int_equal(parsed, 10000);
/* Verify dedup via manual hash-set scan */
fp = fopen(path, "r");
assert_non_null(fp);
GHashTable* aid_set = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
GHashTable* sid_map = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
if (pl->archive_id)
g_hash_table_add(aid_set, g_strdup(pl->archive_id));
if (pl->stanza_id && pl->from_jid)
g_hash_table_insert(sid_map, g_strdup(pl->stanza_id), g_strdup(pl->from_jid));
ff_parsed_line_free(pl);
}
}
fclose(fp);
assert_int_equal((int)g_hash_table_size(aid_set), 10000);
assert_int_equal((int)g_hash_table_size(sid_map), 10000);
assert_true(g_hash_table_contains(aid_set, "stress-aid-0"));
assert_true(g_hash_table_contains(aid_set, "stress-aid-9999"));
const char* s0 = g_hash_table_lookup(sid_map, "stress-sid-0");
assert_string_equal(s0, "a@x.org");
const char* s1 = g_hash_table_lookup(sid_map, "stress-sid-1");
assert_string_equal(s1, "b@x.org");
g_hash_table_destroy(aid_set);
g_hash_table_destroy(sid_map);
_cleanup_dir(dir);
}
void
test_stress_index_extend_append(void** state)
{
/* Write 500 lines, then append 500 more, parse all 1000 */
auto_gchar gchar* dir = _make_tmpdir();
const char* senders[] = { "writer@test.org" };
auto_gchar gchar* path = _write_n_lines(dir, 500, "chat", senders, 1, "initial", 0);
/* Append 500 more lines */
FILE* fp = fopen(path, "a");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 8, 20);
for (int i = 500; i < 1000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32], aid[32];
snprintf(sid, sizeof(sid), "stress-sid-%d", i);
snprintf(aid, sizeof(aid), "stress-aid-%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, aid, NULL,
"writer@test.org", "resource",
NULL, NULL, -1,
"appended message");
}
fclose(fp);
g_date_time_unref(base);
/* Parse all 1000 lines */
fp = fopen(path, "r");
assert_non_null(fp);
int parsed = 0;
GHashTable* aids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
if (pl->archive_id)
g_hash_table_add(aids, g_strdup(pl->archive_id));
ff_parsed_line_free(pl);
}
}
fclose(fp);
assert_int_equal(parsed, 1000);
assert_int_equal((int)g_hash_table_size(aids), 1000);
assert_true(g_hash_table_contains(aids, "stress-aid-999"));
g_hash_table_destroy(aids);
_cleanup_dir(dir);
}
void
test_stress_id_cache_dedup_correctness(void** state)
{
/* Write 2000 lines where every 10th has a duplicate archive_id.
* Verify dedup correctness by parsing and using a manual hash set. */
auto_gchar gchar* dir = _make_tmpdir();
char* path = g_strdup_printf("%s/history.log", dir);
FILE* fp = fopen(path, "w");
assert_non_null(fp);
fprintf(fp, "%s", FLATFILE_HEADER);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < 2000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32], aid[32];
snprintf(sid, sizeof(sid), "dedup-sid-%d", i);
/* Every 10th line (starting from i=10) reuses archive_id "stress-aid-0" */
if (i > 0 && i % 10 == 0) {
snprintf(aid, sizeof(aid), "stress-aid-0");
} else {
snprintf(aid, sizeof(aid), "stress-aid-%d", i);
}
ff_write_line(fp, ts_str, "chat", "none",
sid, aid, NULL,
"sender@x.org", "res",
NULL, NULL, -1,
"dedup test");
}
fclose(fp);
g_date_time_unref(base);
/* Parse and build dedup set */
fp = fopen(path, "r");
assert_non_null(fp);
GHashTable* aid_set = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
GHashTable* sid_set = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
parsed++;
if (pl->archive_id)
g_hash_table_add(aid_set, g_strdup(pl->archive_id));
if (pl->stanza_id && pl->from_jid)
g_hash_table_insert(sid_set, g_strdup(pl->stanza_id), g_strdup(pl->from_jid));
ff_parsed_line_free(pl);
}
}
fclose(fp);
assert_int_equal(parsed, 2000);
/* 2000 stanza IDs are all unique */
assert_int_equal((int)g_hash_table_size(sid_set), 2000);
/* archive_ids: 199 are duplicates (i=10,20,...,1990 all map to stress-aid-0) */
/* unique aids = 2000 - 199 = 1801 */
assert_int_equal((int)g_hash_table_size(aid_set), 1801);
assert_true(g_hash_table_contains(aid_set, "stress-aid-0"));
assert_true(g_hash_table_contains(aid_set, "stress-aid-1999"));
g_hash_table_destroy(aid_set);
g_hash_table_destroy(sid_set);
g_free(path);
_cleanup_dir(dir);
}
/* ================================================================
* Export / import merge dedup
* ================================================================ */
void
test_stress_export_merge_dedup_1000(void** state)
{
/* Simulate merge: write 1000 lines, read them all, write again with
* 500 new lines, verify dedup via hash set */
char tmppath[] = "/tmp/profstress_merge_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
fprintf(fp, "%s", FLATFILE_HEADER);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < 1000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[48];
snprintf(sid, sizeof(sid), "merge-sid-%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, NULL, NULL,
"alice@x.org", "r",
"bob@x.org", NULL, -1,
"existing message");
}
fclose(fp);
/* Read all lines into a dedup set (keyed by stanza_id) */
fp = fopen(tmppath, "r");
assert_non_null(fp);
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
GSList* existing = NULL;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
if (pl->stanza_id)
g_hash_table_add(seen, g_strdup(pl->stanza_id));
existing = g_slist_prepend(existing, pl);
}
}
fclose(fp);
existing = g_slist_reverse(existing);
assert_int_equal((int)g_slist_length(existing), 1000);
/* Now create 500 new + 500 duplicate messages */
int new_count = 0;
int dup_count = 0;
GSList* new_lines = NULL;
for (int i = 0; i < 1000; i++) {
char sid[48];
if (i < 500) {
/* Duplicate */
snprintf(sid, sizeof(sid), "merge-sid-%d", i);
} else {
/* New */
snprintf(sid, sizeof(sid), "merge-sid-new-%d", i);
}
if (g_hash_table_contains(seen, sid)) {
dup_count++;
} else {
g_hash_table_add(seen, g_strdup(sid));
new_count++;
/* Would add to new_lines in real export */
}
}
assert_int_equal(dup_count, 500);
assert_int_equal(new_count, 500);
assert_int_equal((int)g_hash_table_size(seen), 1500);
g_slist_free_full(existing, (GDestroyNotify)ff_parsed_line_free);
g_slist_free(new_lines);
g_hash_table_destroy(seen);
unlink(tmppath);
g_date_time_unref(base);
}
void
test_stress_mixed_types_and_encodings(void** state)
{
/* Write 3000 messages cycling through all message types and encodings */
const char* types[] = { "chat", "muc", "mucpm" };
const char* encs[] = { "none", "omemo", "otr", "pgp", "ox" };
char tmppath[] = "/tmp/profstress_mixed_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < 3000; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
const char* t = types[i % 3];
const char* e = encs[i % 5];
char sid[32];
snprintf(sid, sizeof(sid), "mixed-%d", i);
ff_write_line(fp, ts_str, t, e,
sid, NULL, NULL,
"sender@j.org", "res",
"recv@j.org", NULL, -1,
"mixed type/enc test");
}
fclose(fp);
g_date_time_unref(base);
/* Parse and verify type/enc distribution */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int counts[3] = { 0 }; /* chat, muc, mucpm */
int enc_counts[5] = { 0 }; /* none, omemo, otr, pgp, ox */
int parsed = 0;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (!pl)
continue;
parsed++;
if (g_strcmp0(pl->type, "chat") == 0)
counts[0]++;
else if (g_strcmp0(pl->type, "muc") == 0)
counts[1]++;
else if (g_strcmp0(pl->type, "mucpm") == 0)
counts[2]++;
if (g_strcmp0(pl->enc, "none") == 0)
enc_counts[0]++;
else if (g_strcmp0(pl->enc, "omemo") == 0)
enc_counts[1]++;
else if (g_strcmp0(pl->enc, "otr") == 0)
enc_counts[2]++;
else if (g_strcmp0(pl->enc, "pgp") == 0)
enc_counts[3]++;
else if (g_strcmp0(pl->enc, "ox") == 0)
enc_counts[4]++;
ff_parsed_line_free(pl);
}
fclose(fp);
unlink(tmppath);
assert_int_equal(parsed, 3000);
assert_int_equal(counts[0], 1000);
assert_int_equal(counts[1], 1000);
assert_int_equal(counts[2], 1000);
assert_int_equal(enc_counts[0], 600);
assert_int_equal(enc_counts[1], 600);
assert_int_equal(enc_counts[2], 600);
assert_int_equal(enc_counts[3], 600);
assert_int_equal(enc_counts[4], 600);
}
/* ================================================================
* LMC correction chains under load
* ================================================================ */
void
test_stress_lmc_chain_deep(void** state)
{
/* Build a chain of 90 corrections (under FF_MAX_LMC_DEPTH=100).
* Write: msg0 (stanza_id=c0), msg1 (stanza_id=c1, corrects=c0), ...,
* msg89 (stanza_id=c89, corrects=c88).
* After LMC resolution, result should have 1 message with text from c89. */
const int chain_len = 90;
char tmppath[] = "/tmp/profstress_lmc_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
for (int i = 0; i < chain_len; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32], rid[32];
snprintf(sid, sizeof(sid), "c%d", i);
const char* replace = NULL;
if (i > 0) {
snprintf(rid, sizeof(rid), "c%d", i - 1);
replace = rid;
}
char body[64];
snprintf(body, sizeof(body), "correction-v%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, NULL, replace,
"alice@x.org", NULL,
NULL, NULL, -1,
body);
}
fclose(fp);
g_date_time_unref(base);
/* Read all lines, parse, build parsed list */
fp = fopen(tmppath, "r");
assert_non_null(fp);
GSList* parsed_lines = NULL;
while (1) {
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl)
parsed_lines = g_slist_prepend(parsed_lines, pl);
}
fclose(fp);
unlink(tmppath);
parsed_lines = g_slist_reverse(parsed_lines);
assert_int_equal((int)g_slist_length(parsed_lines), chain_len);
/* The _ff_apply_lmc is static, so we test its effect indirectly:
* We use ff_parsed_to_profmessage and manual chain resolution logic
* that mirrors what _ff_apply_lmc does. */
/* Build id_map */
GHashTable* id_map = g_hash_table_new(g_str_hash, g_str_equal);
for (GSList* l = parsed_lines; l; l = l->next) {
ff_parsed_line_t* pl = l->data;
if (pl->stanza_id && strlen(pl->stanza_id) > 0)
g_hash_table_insert(id_map, pl->stanza_id, pl);
}
/* Find root of the chain */
ff_parsed_line_t* first = parsed_lines->data;
assert_null(first->replace_id);
assert_string_equal(first->stanza_id, "c0");
/* Follow the chain from last correction */
ff_parsed_line_t* last = g_slist_last(parsed_lines)->data;
assert_string_equal(last->stanza_id, "c89");
assert_string_equal(last->message, "correction-v89");
/* Verify chain integrity */
int depth = 0;
ff_parsed_line_t* cur = last;
while (cur->replace_id && strlen(cur->replace_id) > 0 && depth < FF_MAX_LMC_DEPTH) {
ff_parsed_line_t* parent = g_hash_table_lookup(id_map, cur->replace_id);
assert_non_null(parent);
cur = parent;
depth++;
}
assert_int_equal(depth, chain_len - 1);
assert_string_equal(cur->stanza_id, "c0");
g_hash_table_destroy(id_map);
g_slist_free_full(parsed_lines, (GDestroyNotify)ff_parsed_line_free);
}
void
test_stress_lmc_many_corrections(void** state)
{
/* 500 original messages, each corrected once = 1000 lines total.
* After LMC, should produce 500 messages with corrected text. */
const int n_originals = 500;
char tmppath[] = "/tmp/profstress_lmcmany_XXXXXX";
int fd = mkstemp(tmppath);
assert_true(fd >= 0);
FILE* fp = fdopen(fd, "w");
assert_non_null(fp);
GDateTime* base = g_date_time_new_utc(2026, 1, 1, 0, 0, 0);
/* Write originals */
for (int i = 0; i < n_originals; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)i);
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32];
snprintf(sid, sizeof(sid), "orig-%d", i);
char body[64];
snprintf(body, sizeof(body), "original-%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, NULL, NULL,
"alice@x.org", NULL,
NULL, NULL, -1,
body);
}
/* Write corrections */
for (int i = 0; i < n_originals; i++) {
GDateTime* ts = g_date_time_add_seconds(base, (double)(n_originals + i));
auto_gchar gchar* ts_str = g_date_time_format_iso8601(ts);
g_date_time_unref(ts);
char sid[32], rid[32];
snprintf(sid, sizeof(sid), "corr-%d", i);
snprintf(rid, sizeof(rid), "orig-%d", i);
char body[64];
snprintf(body, sizeof(body), "corrected-%d", i);
ff_write_line(fp, ts_str, "chat", "none",
sid, NULL, rid,
"alice@x.org", NULL,
NULL, NULL, -1,
body);
}
fclose(fp);
g_date_time_unref(base);
/* Parse and verify structure */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int total_parsed = 0;
int originals_found = 0;
int corrections_found = 0;
while (1) {
auto_char char* buf = ff_readline(fp, NULL);
if (!buf)
break;
// ff_parse_line already rejects empty/comment lines, no need to pre-filter.
ff_parsed_line_t* pl = ff_parse_line(buf);
if (pl) {
total_parsed++;
if (pl->replace_id && strlen(pl->replace_id) > 0)
corrections_found++;
else
originals_found++;
ff_parsed_line_free(pl);
}
}
fclose(fp);
unlink(tmppath);
assert_int_equal(total_parsed, n_originals * 2);
assert_int_equal(originals_found, n_originals);
assert_int_equal(corrections_found, n_originals);
}