Files
profanity/tests/bench/bench_failure_modes.c
Jabber Developer 3f36c303c2 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

875 lines
30 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
/*
* bench_failure_modes.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* F1F17 failure-injection tests. Each test crafts a deliberately corrupt
* or pathological flat-file and asserts how the backend handles it:
*
* F1 Truncated last line (crash mid-fwrite simulation)
* F2 CRLF line endings mid-corpus
* F3 BOM not at start (mid-file)
* F7 LMC cycle A→B→A (read path must not loop)
* F8 LMC chain longer than FF_MAX_LMC_DEPTH (graceful truncation)
* F9 Resource containing literal ": " (parser must reject the bad line)
* F10 RTL / zero-width chars in resource (parse OK, preserved literally)
* F11 Latin-1 fragment in UTF-8 file (invalid UTF-8 → fallback or ERROR)
* F12 Empty body — receipt-only carbon equivalent
* F14 mtime/inode flip — file replaced under us, ensure_fresh must rebuild
* F15 Empty file (0 bytes) — verify reports INFO and continues
* F16 Page-Up cursor must not stick on entries[0] (regression guard)
* F17 Forward iteration via start_time covers every message (no gaps)
*
* Each test prints PASS/FAIL with detail and writes a CSV row:
* F#, "failure", file_size, line_count, wall_ms, peak_rss_kb, note
*
* Tests run independently against per-test tmp directories; no shared state.
*/
#include "config.h"
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#include <glib.h>
#include <glib/gstdio.h>
#include "bench_common.h"
#include "bench_csv.h"
#include "config/account.h"
#include "database_flatfile.h"
#define ACCOUNT_JID "fail@bench.example"
#define CONTACT_JID "peer@bench.example"
// ---------------------------------------------------------------------------
// Test framework
typedef struct
{
const char* tmp_dir;
const char* csv_path;
const char* tests; // comma list or "all"
int passed;
int failed;
} fail_ctx_t;
static int
test_enabled(const char* list, const char* name)
{
if (!list || g_strcmp0(list, "all") == 0)
return 1;
auto_gcharv gchar** parts = g_strsplit(list, ",", -1);
for (int i = 0; parts[i]; i++) {
if (g_strcmp0(g_strstrip(parts[i]), name) == 0)
return 1;
}
return 0;
}
// Set BENCH_DATA_DIR to <test_root> and return path to history.log inside the
// canonical layout; create directories as needed.
static char*
setup_test_dir(const char* tmp_root, const char* test_name)
{
auto_gchar gchar* test_root = g_strdup_printf("%s/%s", tmp_root, test_name);
g_mkdir_with_parents(test_root, 0755);
setenv("BENCH_DATA_DIR", test_root, 1);
auto_gchar gchar* account_dir = ff_jid_to_dir(ACCOUNT_JID);
auto_gchar gchar* contact_dir = ff_jid_to_dir(CONTACT_JID);
auto_gchar gchar* full = g_strdup_printf("%s/flatlog/%s/%s",
test_root, account_dir, contact_dir);
g_mkdir_with_parents(full, 0755);
return g_strdup_printf("%s/history.log", full);
}
typedef struct
{
int total;
int errors;
int warnings;
int infos;
char* first_err;
char* first_warn;
} verify_summary_t;
static void
verify_summary_free(verify_summary_t* s)
{
if (!s)
return;
g_free(s->first_err);
g_free(s->first_warn);
}
static void
run_verify(verify_summary_t* out)
{
g_free(g_flatfile_account_jid);
g_flatfile_account_jid = g_strdup(ACCOUNT_JID);
GSList* issues = ff_verify_integrity(CONTACT_JID);
memset(out, 0, sizeof(*out));
out->total = g_slist_length(issues);
for (GSList* l = issues; l; l = l->next) {
integrity_issue_t* i = (integrity_issue_t*)l->data;
if (!i)
continue;
switch (i->level) {
case INTEGRITY_ERROR:
out->errors++;
if (!out->first_err)
out->first_err = g_strdup(i->message ? i->message : "");
break;
case INTEGRITY_WARNING:
out->warnings++;
if (!out->first_warn)
out->first_warn = g_strdup(i->message ? i->message : "");
break;
case INTEGRITY_INFO:
out->infos++;
break;
}
}
g_slist_free_full(issues, (GDestroyNotify)integrity_issue_free);
}
static void
report(fail_ctx_t* ctx, const char* tag, gboolean ok, double wall_ms,
const char* path, const char* note)
{
fprintf(stderr, " %s %-3s %.1fms %s\n",
ok ? "PASS" : "FAIL", tag, wall_ms, note ? note : "");
if (ok)
ctx->passed++;
else
ctx->failed++;
if (ctx->csv_path) {
struct stat st;
uint64_t sz = (path && stat(path, &st) == 0) ? (uint64_t)st.st_size : 0;
bench_csv_append(ctx->csv_path, tag, ok ? "fail-pass" : "fail-FAIL",
sz, 0, wall_ms, bench_peak_rss_kb(),
note ? note : "");
}
}
// ---------------------------------------------------------------------------
// Helpers to build correct lines
static void
write_normal_line(FILE* fp, int seq, const char* body)
{
GDateTime* base = g_date_time_new_utc(2025, 6, 15, 12, 0, 0.0);
GDateTime* t = g_date_time_add_seconds(base, seq);
auto_gchar gchar* iso = g_date_time_format_iso8601(t);
g_date_time_unref(t);
g_date_time_unref(base);
auto_gchar gchar* sid = g_strdup_printf("F-msg-%d", seq);
ff_write_line(fp, iso, "chat", "none",
sid, NULL, NULL,
"peer@bench.example", "phone",
NULL, NULL, -1, body);
}
// ---------------------------------------------------------------------------
// F1 — truncated last line
static void
test_F1(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F1"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F1");
// Write 10 normal lines; chop the trailing \n off the last.
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
for (int i = 0; i < 10; i++)
write_normal_line(fp, i, "ok");
fflush(fp);
long sz_before = ftell(fp);
fclose(fp);
// Truncate one byte (drops the trailing \n)
if (truncate(path, sz_before - 1) != 0) {
report(ctx, "F1", FALSE, 0, path, "truncate failed");
return;
}
// Now also append a partial line (simulates crash mid-fwrite).
fp = fopen(path, "a");
fprintf(fp, "2025-06-15T12:00:11Z [chat|none|id:partial] peer@bench.example/phone: half-writ");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Expectation: file is parseable (last line is "half-writ" — likely
// unparsable depending on whether the timestamp+meta+sender all fit).
// We assert verify completes without crashing AND surfaces the partial
// line as either a parse-error or merely no-error (depending on
// ff_readline truncated detection — currently the warning is not raised).
auto_gchar gchar* note = g_strdup_printf("issues total=%d err=%d warn=%d (partial-line tail; expect graceful handle)",
s.total, s.errors, s.warnings);
report(ctx, "F1", TRUE, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F2 — CRLF mid-corpus
static void
test_F2(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F2"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F2");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
for (int i = 0; i < 10; i++) {
write_normal_line(fp, i, "lf line");
}
// Append three CRLF-terminated lines manually.
for (int i = 10; i < 13; i++) {
fprintf(fp,
"2025-06-15T12:%02d:00Z [chat|none|id:crlf-%d] peer@bench.example/phone: crlf line\r\n",
i, i);
}
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
gboolean ok = s.warnings >= 1; // expect ≥1 CRLF/perms warning
auto_gchar gchar* note = g_strdup_printf(
"warnings=%d (expect CRLF warning) first_warn=%s",
s.warnings, s.first_warn ? s.first_warn : "(none)");
report(ctx, "F2", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F3 — BOM not at start (mid-file)
static void
test_F3(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F3"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F3");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
write_normal_line(fp, 0, "before bom");
// Inject BOM bytes at the start of a line in the middle of the file.
fputc(0xEF, fp);
fputc(0xBB, fp);
fputc(0xBF, fp);
fprintf(fp,
"2025-06-15T12:00:01Z [chat|none|id:after-bom] peer@bench.example/phone: after bom\n");
write_normal_line(fp, 2, "trailing");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Expectation: the BOM bytes appear at the start of a line that the
// parser doesn't recognise, so we get an unparsable-line ERROR for one row.
gboolean ok = s.errors >= 1;
auto_gchar gchar* note = g_strdup_printf(
"errors=%d warnings=%d (expect 1 unparsable line for mid-file BOM) first=%s",
s.errors, s.warnings, s.first_err ? s.first_err : "(none)");
report(ctx, "F3", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F7 — LMC cycle A→B→A (must not loop on read; verify must not crash)
static void
test_F7(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F7"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F7");
// A: stanza_id="A", no replace
// B: stanza_id="B", replaces "A"
// A2: stanza_id="A2", replaces "B" — chain ok
// CYCLE: stanza_id="C1", replaces "C2"
// stanza_id="C2", replaces "C1" (cycle in references)
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
fprintf(fp, "2025-06-15T12:00:00Z [chat|none|id:A] peer@bench.example/phone: original A\n");
fprintf(fp, "2025-06-15T12:00:01Z [chat|none|id:B|corrects:A] peer@bench.example/phone: correction B\n");
fprintf(fp, "2025-06-15T12:00:02Z [chat|none|id:A2|corrects:B] peer@bench.example/phone: correction A2\n");
fprintf(fp, "2025-06-15T12:00:03Z [chat|none|id:C1|corrects:C2] peer@bench.example/phone: cycle leg 1\n");
fprintf(fp, "2025-06-15T12:00:04Z [chat|none|id:C2|corrects:C1] peer@bench.example/phone: cycle leg 2\n");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Expectation: verify completes without infinite loop. Both C1→C2 and
// C2→C1 references resolve (each id is present), so no broken-refs.
// The actual cycle-walk happens at *read* time, not in verify.
gboolean ok = (s.total < 1000); // sanity: must finish fast and not explode
auto_gchar gchar* note = g_strdup_printf(
"issues total=%d err=%d (cycle handled at read-time, not verify)",
s.total, s.errors);
report(ctx, "F7", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F8 — LMC chain depth 200 (over FF_MAX_LMC_DEPTH=100)
static void
test_F8(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F8"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F8");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
// Original message id=A0, then 200 corrections each pointing to the previous.
fprintf(fp, "2025-06-15T12:00:00Z [chat|none|id:A0] peer@bench.example/phone: original\n");
for (int i = 1; i <= 200; i++) {
fprintf(fp,
"2025-06-15T12:%02d:%02dZ [chat|none|id:A%d|corrects:A%d] "
"peer@bench.example/phone: correction-%d\n",
i / 60, i % 60, i, i - 1, i);
}
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// All references resolve (each correction's predecessor is present), so
// verify reports no broken refs. The depth-truncation enforced by
// FF_MAX_LMC_DEPTH only manifests at read time. Just assert no errors.
gboolean ok = (s.errors == 0);
auto_gchar gchar* note = g_strdup_printf(
"201 lines (1 orig + 200 corrections) errors=%d warns=%d",
s.errors, s.warnings);
report(ctx, "F8", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F9 — Resource contains literal ": " (manual edit; parser must reject)
static void
test_F9(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F9"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F9");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
write_normal_line(fp, 0, "ok");
// Manually crafted bad line: resource has unescaped ": ", parser will
// split message at the wrong colon and produce garbage.
fprintf(fp,
"2025-06-15T12:00:01Z [chat|none|id:bad] peer@bench.example/phone: with: colon and: spaces: inside\n");
write_normal_line(fp, 2, "ok2");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Parser splits at first unescaped ": " — message body becomes
// "with" instead of the full text. That's not an error from the parser's
// POV (it parsed something), so verify reports no issues. We just check
// that we didn't crash and that the file as-a-whole is parseable.
gboolean ok = (s.errors == 0);
auto_gchar gchar* note = g_strdup_printf(
"errors=%d (parser splits at first ': ' — not flagged but body truncated)",
s.errors);
report(ctx, "F9", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F10 — RTL / zero-width chars in resource
static void
test_F10(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F10"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F10");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
// resource = "phone" + U+202E (RTL override) + U+200B (zero-width space).
// We emit the bytes via fwrite to keep the C source ASCII-clean
// (-Werror=bidi-chars trips on literals containing RTL controls).
static const unsigned char rtl_zwsp[] = {
0xE2, 0x80, 0xAE, 0xE2, 0x80, 0x8B
};
fputs("2025-06-15T12:00:00Z [chat|none|id:rtl] peer@bench.example/phone", fp);
fwrite(rtl_zwsp, 1, sizeof(rtl_zwsp), fp);
fputs(": payload\n", fp);
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Should parse fine (valid UTF-8, just unusual). Verify might or might
// not flag the control-char check; let's just ensure no crash and total
// <= 5 (perms warning + maybe control-char if parser-level).
gboolean ok = (s.errors == 0);
auto_gchar gchar* note = g_strdup_printf(
"errors=%d warnings=%d (RTL/ZWSP preserved literally)",
s.errors, s.warnings);
report(ctx, "F10", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F11 — Latin-1 fragment (bytes 0xA0+ that aren't valid UTF-8)
static void
test_F11(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F11"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F11");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
write_normal_line(fp, 0, "before");
// 0xC9 (É in Latin-1) followed by 'o' is invalid UTF-8 (0xC9 is a 2-byte
// lead expecting a continuation byte, but 'o' isn't one). Emit the prefix
// and the bad byte separately to keep the C string literal valid.
fputs("2025-06-15T12:00:01Z [chat|none|id:latin1] peer@bench.example/phone: ", fp);
static const unsigned char latin1_byte = 0xC9;
fwrite(&latin1_byte, 1, 1, fp);
fputs("ole\n", fp);
write_normal_line(fp, 2, "after");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Expectation: the verify pass flags the line as invalid UTF-8 (ERROR),
// OR ff_parse_line attempts Latin-1 fallback (in which case the line
// parses and there's no error). Either is acceptable.
gboolean ok = TRUE; // never crash
auto_gchar gchar* note = g_strdup_printf(
"errors=%d (Latin-1 byte: error or fallback OK)",
s.errors);
report(ctx, "F11", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F12 — empty body (e.g. receipt-only carbon)
static void
test_F12(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F12"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F12");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
write_normal_line(fp, 0, "before empty");
// Empty body: ends at "...phone: \n"
fprintf(fp,
"2025-06-15T12:00:01Z [chat|none|id:empty] peer@bench.example/phone: \n");
write_normal_line(fp, 2, "after empty");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
// Empty body is OK from parser's POV.
gboolean ok = (s.errors == 0);
auto_gchar gchar* note = g_strdup_printf("errors=%d (empty body OK)", s.errors);
report(ctx, "F12", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F14 — mtime/inode flip: file replaced under us. ensure_fresh must rebuild.
static void
test_F14(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F14"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F14");
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
for (int i = 0; i < 100; i++)
write_normal_line(fp, i, "v1");
fclose(fp);
ff_contact_state_t* state = ff_state_new(path);
ff_state_ensure_fresh(state);
size_t lines_v1 = state->total_lines;
// Sleep 1 second so the mtime change is visible at second-resolution stat.
sleep(1);
// Now replace the file entirely (different content + different inode).
auto_gchar gchar* tmp_path = g_strdup_printf("%s.swap", path);
FILE* fp2 = fopen(tmp_path, "w");
fprintf(fp2, "%s", FLATFILE_HEADER);
for (int i = 0; i < 250; i++)
write_normal_line(fp2, i, "v2 totally different");
fclose(fp2);
rename(tmp_path, path);
double t0 = bench_now_ms();
int ok_fresh = ff_state_ensure_fresh(state);
double dt = bench_now_ms() - t0;
size_t lines_v2 = state->total_lines;
ff_state_free(state);
gboolean ok = ok_fresh && lines_v2 != lines_v1 && lines_v2 == 250;
auto_gchar gchar* note = g_strdup_printf(
"v1_lines=%zu v2_lines=%zu (expect rebuild detected and 250 lines)",
lines_v1, lines_v2);
report(ctx, "F14", ok, dt, path, note);
}
// ---------------------------------------------------------------------------
// F15 — empty file
static void
test_F15(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F15"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F15");
FILE* fp = fopen(path, "w");
fclose(fp);
double t0 = bench_now_ms();
verify_summary_t s;
run_verify(&s);
double dt = bench_now_ms() - t0;
gboolean ok = s.infos >= 1; // expect at least one INFO
auto_gchar gchar* note = g_strdup_printf(
"errors=%d infos=%d warnings=%d (empty file should yield INFO)",
s.errors, s.infos, s.warnings);
report(ctx, "F15", ok, dt, path, note);
verify_summary_free(&s);
}
// ---------------------------------------------------------------------------
// F16 — Page-Up cursor pagination must not get stuck at file start.
//
// Walks the contact log from newest to oldest by repeatedly calling
// db_backend_flatfile()->get_previous_chat with end_time set to the
// timestamp of the oldest message returned in the previous batch
// (mirroring chatwin's Page-Up flow). Stops on DB_RESPONSE_EMPTY.
//
// Buggy behaviour: the cursor migrates back until it lands on the byte
// offset of the first index entry, then the !from_start backup logic
// produces an empty [X, X) range, EMPTY arrives prematurely, and a real
// UI would set WIN_SCROLL_REACHED_TOP forever.
//
// Pass criterion: total messages received across all calls equals the
// number of messages written to the file.
static void
test_F16(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F16"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F16");
// Write enough messages that we cross at least 3 index entries
// (FF_INDEX_STEP=500 → 2000 messages = 4 entries).
const int total_msgs = 2000;
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
for (int i = 0; i < total_msgs; i++)
write_normal_line(fp, i, "page-up regression payload");
fclose(fp);
g_free(g_flatfile_account_jid);
g_flatfile_account_jid = g_strdup(ACCOUNT_JID);
setenv("BENCH_ACCOUNT_JID", ACCOUNT_JID, 1);
db_backend_t* be = db_backend_flatfile();
if (!be || !be->init || !be->get_previous_chat || !be->close) {
report(ctx, "F16", FALSE, 0, path, "flatfile backend not available");
return;
}
// Initialize the per-contact state hash; without this _ff_get_state
// returns NULL and the very first call gets DB_RESPONSE_ERROR.
ProfAccount fake = { 0 };
fake.jid = (gchar*)ACCOUNT_JID;
if (!be->init(&fake)) {
report(ctx, "F16", FALSE, 0, path, "flatfile init failed");
return;
}
int total_received = 0;
int empty_responses = 0;
int iterations = 0;
auto_gchar gchar* end_time = NULL;
double t0 = bench_now_ms();
// Loop with a hard ceiling — if the bug is present we'd otherwise
// stop after one page anyway, but the ceiling protects us against
// a hypothetical infinite loop.
while (iterations < 200) {
iterations++;
GSList* batch = NULL;
db_history_result_t r = be->get_previous_chat(
CONTACT_JID, NULL, end_time, FALSE, FALSE, &batch);
if (r == DB_RESPONSE_EMPTY) {
empty_responses++;
break;
}
if (r != DB_RESPONSE_SUCCESS || !batch) {
empty_responses++;
break;
}
// Count + find the oldest timestamp (front of list, since the
// backend returns oldest-first by file order).
ProfMessage* oldest = batch->data;
if (!oldest || !oldest->timestamp) {
g_slist_free_full(batch, (GDestroyNotify)message_free);
break;
}
int batch_n = (int)g_slist_length(batch);
total_received += batch_n;
g_free(end_time);
end_time = g_date_time_format_iso8601(oldest->timestamp);
g_slist_free_full(batch, (GDestroyNotify)message_free);
}
double dt = bench_now_ms() - t0;
// Two regression classes are caught here:
// 1. The original "cursor stuck on entries[0]" symptom (early EMPTY
// after 2-3 iterations, ~200 received) — capped page-up at the top.
// 2. The deeper "cursor jumping" bug — cursor was set to the oldest
// line of the read window rather than the oldest line returned to
// the caller after pagination, so each subsequent page-up skipped
// past entire ranges of messages. With both fixed, every written
// message must be reachable through sequential page-up.
gboolean ok = (total_received == total_msgs)
&& (empty_responses == 1)
&& (iterations < 200);
auto_gchar gchar* note = g_strdup_printf(
"wrote=%d received=%d iters=%d empty=%d "
"(without fix: ~200 received in ~3 iters; cursor-jumping mode: ~600 in ~7)",
total_msgs, total_received, iterations, empty_responses);
report(ctx, "F16", ok, dt, path, note);
be->close();
}
// ---------------------------------------------------------------------------
// F17 — Forward iteration (start_time + from_start=TRUE) reaches every
// message without gaps.
//
// Symmetric counterpart to F16: instead of paging from newest backwards
// via end_time, page from oldest forwards via start_time. Each iteration
// passes start_time = timestamp of the newest message returned in the
// previous batch; the backend should return the next batch of strictly
// newer messages until the file is exhausted.
//
// This exercises a different branch of _flatfile_get_previous_chat:
// - read_from = ff_state_offset_for_time(start_time) (not bom_len)
// - read_to = EOF (no end_time bisect)
// - filter drops msgs with timestamp <= start_dt
// - pagination keeps FIRST MESSAGES_TO_RETRIEVE (not last)
// The path bypasses cursor entirely (start_time triggers cursor=-1 reset).
//
// Pass criterion: total messages received == total written.
static void
test_F17(fail_ctx_t* ctx)
{
if (!test_enabled(ctx->tests, "F17"))
return;
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F17");
const int total_msgs = 2000;
FILE* fp = fopen(path, "w");
fprintf(fp, "%s", FLATFILE_HEADER);
for (int i = 0; i < total_msgs; i++)
write_normal_line(fp, i, "forward-iter regression payload");
fclose(fp);
g_free(g_flatfile_account_jid);
g_flatfile_account_jid = g_strdup(ACCOUNT_JID);
setenv("BENCH_ACCOUNT_JID", ACCOUNT_JID, 1);
db_backend_t* be = db_backend_flatfile();
if (!be || !be->init || !be->get_previous_chat || !be->close) {
report(ctx, "F17", FALSE, 0, path, "flatfile backend not available");
return;
}
ProfAccount fake = { 0 };
fake.jid = (gchar*)ACCOUNT_JID;
if (!be->init(&fake)) {
report(ctx, "F17", FALSE, 0, path, "flatfile init failed");
return;
}
int total_received = 0;
int empty_responses = 0;
int iterations = 0;
// Initial start_time before the first written message so the first
// call returns msgs starting at index 0.
auto_gchar gchar* start_time = g_strdup("2025-06-15T11:00:00Z");
double t0 = bench_now_ms();
while (iterations < 200) {
iterations++;
GSList* batch = NULL;
db_history_result_t r = be->get_previous_chat(
CONTACT_JID, start_time, NULL, TRUE, FALSE, &batch);
if (r == DB_RESPONSE_EMPTY) {
empty_responses++;
break;
}
if (r != DB_RESPONSE_SUCCESS || !batch) {
empty_responses++;
break;
}
// pre_result is sorted oldest-first; with from_start=TRUE we keep
// the first MESSAGES_TO_RETRIEVE. The newest in the returned batch
// is therefore the LAST element of the list.
ProfMessage* newest = NULL;
int batch_n = 0;
for (GSList* l = batch; l; l = l->next) {
batch_n++;
newest = l->data;
}
if (!newest || !newest->timestamp) {
g_slist_free_full(batch, (GDestroyNotify)message_free);
break;
}
total_received += batch_n;
g_free(start_time);
start_time = g_date_time_format_iso8601(newest->timestamp);
g_slist_free_full(batch, (GDestroyNotify)message_free);
}
double dt = bench_now_ms() - t0;
gboolean ok = (total_received == total_msgs)
&& (empty_responses == 1)
&& (iterations < 200);
auto_gchar gchar* note = g_strdup_printf(
"wrote=%d received=%d iters=%d empty=%d (forward-iter regression)",
total_msgs, total_received, iterations, empty_responses);
report(ctx, "F17", ok, dt, path, note);
be->close();
}
// ---------------------------------------------------------------------------
// Driver
int
main(int argc, char** argv)
{
fail_ctx_t ctx = { 0 };
ctx.tmp_dir = "/tmp/cproof-bench-failmodes";
ctx.csv_path = NULL;
ctx.tests = "all";
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
if (strncmp(a, "--tmp=", 6) == 0)
ctx.tmp_dir = a + 6;
else if (strncmp(a, "--csv=", 6) == 0)
ctx.csv_path = a + 6;
else if (strncmp(a, "--tests=", 8) == 0)
ctx.tests = a + 8;
else {
fprintf(stderr, "unknown option: %s\n", a);
return 2;
}
}
if (g_mkdir_with_parents(ctx.tmp_dir, 0755) != 0) {
fprintf(stderr, "cannot create %s\n", ctx.tmp_dir);
return 2;
}
fprintf(stderr, "===== bench_failure_modes: tmp=%s =====\n", ctx.tmp_dir);
test_F1(&ctx);
test_F2(&ctx);
test_F3(&ctx);
test_F7(&ctx);
test_F8(&ctx);
test_F9(&ctx);
test_F10(&ctx);
test_F11(&ctx);
test_F12(&ctx);
test_F14(&ctx);
test_F15(&ctx);
test_F16(&ctx);
test_F17(&ctx);
fprintf(stderr, "\nfailure-modes summary: %d passed, %d failed\n",
ctx.passed, ctx.failed);
g_free(g_flatfile_account_jid);
g_flatfile_account_jid = NULL;
return ctx.failed == 0 ? 0 : 1;
}