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
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>
1138 lines
34 KiB
C
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);
|
|
}
|