Compare commits

...

15 Commits

Author SHA1 Message Date
bdbd588d90 fix(verify): per-contact context in output, demote duplicate stanza-id to debug 2026-05-06 14:19:46 +03:00
26db671a38 fix(export): hash stanza_id with content instead of trusting it as sole dedup key
All checks were successful
CI Code / Check spelling (pull_request) Successful in 26s
CI Code / Check coding style (pull_request) Successful in 1m6s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m50s
CI Code / Linux (arch) (pull_request) Successful in 5m25s
CI Code / Linux (debian) (pull_request) Successful in 6m50s
2026-05-05 16:06:54 +03:00
2990ec795a refactor(flatfile): single-line file header with format-version marker
All checks were successful
CI Code / Check spelling (pull_request) Successful in 1m32s
CI Code / Check coding style (pull_request) Successful in 3m14s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (debian) (pull_request) Successful in 4m50s
CI Code / Linux (arch) (pull_request) Successful in 5m33s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m51s
2026-05-05 14:56:07 +03:00
b8a4900393 fix(jid): treat NULL/empty/"(null)" barejid the same as resource
Some checks failed
CI Code / Check spelling (pull_request) Successful in 2m0s
CI Code / Check coding style (pull_request) Failing after 2m10s
CI Code / Linux (debian) (pull_request) Successful in 8m29s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m33s
CI Code / Code Coverage (pull_request) Successful in 6m50s
CI Code / Linux (arch) (pull_request) Successful in 10m30s
2026-05-04 18:52:39 +03:00
3daee1216b fix(jid): treat NULL/empty/"(null)" resource as bare jid
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 13m44s
CI Code / Check spelling (pull_request) Failing after 14m13s
CI Code / Check coding style (pull_request) Failing after 14m43s
CI Code / Linux (ubuntu) (pull_request) Failing after 15m16s
CI Code / Linux (debian) (pull_request) Failing after 15m53s
CI Code / Linux (arch) (pull_request) Failing after 16m23s
2026-05-04 18:29:55 +03:00
024e4b2336 style(flatfile): drop vim modeline from log file header 2026-05-04 18:29:55 +03:00
09b2295414 fix(export): open sqlite on demand when flatfile is active backend
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m27s
CI Code / Check spelling (pull_request) Failing after 12m56s
CI Code / Check coding style (pull_request) Failing after 13m33s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m3s
CI Code / Linux (debian) (pull_request) Failing after 14m38s
CI Code / Linux (arch) (pull_request) Failing after 15m12s
2026-05-04 17:35:59 +03:00
16aa569329 feat(history): /history backend command and status-bar indicator
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m31s
CI Code / Check spelling (pull_request) Failing after 11m2s
CI Code / Check coding style (pull_request) Failing after 11m33s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m2s
CI Code / Linux (debian) (pull_request) Failing after 12m33s
CI Code / Linux (arch) (pull_request) Failing after 13m5s
2026-05-04 17:17:41 +03:00
48b89460b7 feat(flatfile): warn on missing or mismatched log format-version 2026-05-04 17:17:33 +03:00
a9b181beb7 test(bench): add F17 forward-iteration coverage symmetric to F16 2026-05-04 17:16:01 +03:00
52f97f7195 feat(history): autocomplete JID for verify/export/import
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m11s
CI Code / Check spelling (pull_request) Failing after 12m14s
CI Code / Check coding style (pull_request) Failing after 12m19s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m25s
CI Code / Linux (debian) (pull_request) Failing after 12m28s
CI Code / Linux (arch) (pull_request) Failing after 12m32s
2026-05-04 15:42:19 +03:00
352eb26f71 refactor(flatfile): drop dead cursor_offset, guard inverted range
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m25s
CI Code / Check coding style (pull_request) Successful in 2m11s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m47s
CI Code / Linux (debian) (pull_request) Successful in 7m58s
CI Code / Code Coverage (pull_request) Failing after 11m18s
CI Code / Linux (arch) (pull_request) Failing after 11m32s
2026-05-04 15:30:45 +03:00
875f8f4100 fix(flatfile): drop cursor optim + guard n_entries==0
Some checks failed
CI Code / Check spelling (pull_request) Failing after 22s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Linux (debian) (pull_request) Successful in 5m23s
CI Code / Linux (arch) (pull_request) Successful in 6m1s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m18s
CI Code / Code Coverage (pull_request) Successful in 7m32s
Cursor was set to oldest of read window, not oldest of returned
batch — each page-up jumped past entire ranges (F16: 600/2000 msgs).
Always bisect now; correct + simpler. Cost: +5-10 ms per page-up at
1M msgs, well below UI threshold.

Also guard state->entries NULL when total_lines>0 && n_entries==0
(every line failed _ff_maybe_index_line). Backup would segfault.

F16 tightened to received == total_msgs. Catches both stuck-on-
entries[0] and cursor-jumping regressions.
2026-05-04 15:09:51 +03:00
a7da9e2add fix(flatfile): page-up cursor must not stick on entries[0]
All checks were successful
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Code Coverage (pull_request) Successful in 2m39s
CI Code / Linux (debian) (pull_request) Successful in 4m51s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m47s
CI Code / Linux (arch) (pull_request) Successful in 9m59s
When the cursor lands on entries[0].byte_offset, the !from_start
backup logic produces an empty [X, X) range, get_previous_chat
returns DB_RESPONSE_EMPTY, the UI sets WIN_SCROLL_REACHED_TOP and
never tries again — page-up is permanently stuck.

Guard the cursor optimisation with two conditions: index must be
non-empty AND cursor must not equal entries[0].byte_offset. When
either fails, fall through to the end_time bisect path which
correctly locates the byte range with older messages.

F16 regression test in bench_failure_modes:
  without guard: received=200 in 3 iters (premature EMPTY)
  with guard:    received=600 in 7 iters (truly reaches top)
2026-05-04 12:14:54 +03:00
1fe3808b23 style(export): gchar*/g_free consistency + auto_char in read loop
All checks were successful
CI Code / Check spelling (pull_request) Successful in 2m12s
CI Code / Check coding style (pull_request) Successful in 2m23s
CI Code / Linux (arch) (pull_request) Successful in 5m35s
CI Code / Linux (debian) (pull_request) Successful in 6m59s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m41s
CI Code / Code Coverage (pull_request) Successful in 6m59s
2026-05-02 16:18:48 +03:00
15 changed files with 566 additions and 106 deletions

View File

@@ -1146,6 +1146,7 @@ cmd_ac_init(void)
autocomplete_add(history_ac, "on");
autocomplete_add(history_ac, "off");
autocomplete_add(history_ac, "backend");
autocomplete_add(history_ac, "switch");
autocomplete_add(history_ac, "verify");
autocomplete_add(history_ac, "export");
@@ -1806,6 +1807,14 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
return result;
}
gchar* history_jid_subcmds[] = { "/history verify", "/history export", "/history import" };
for (int i = 0; i < ARRAY_SIZE(history_jid_subcmds); i++) {
result = autocomplete_param_with_func(input, history_jid_subcmds[i], roster_barejid_autocomplete, previous, NULL);
if (result) {
return result;
}
}
result = autocomplete_param_with_ac(input, "/history", history_ac, TRUE, previous);
if (result) {
return result;

View File

@@ -1882,6 +1882,7 @@ static const struct cmd_t command_defs[] = {
CMD_TAG_CHAT)
CMD_SYN(
"/history on|off",
"/history backend",
"/history switch sqlite|flatfile",
"/history verify [<jid>]",
"/history export [<jid>]",
@@ -1889,12 +1890,14 @@ static const struct cmd_t command_defs[] = {
CMD_DESC(
"Switch chat history on or off, /logging chat will automatically be enabled when this setting is on. "
"When history is enabled, previous messages are shown in chat windows. "
"Use 'backend' to show the active database backend. "
"Use 'switch' to change the active database backend at runtime without reconnecting. "
"Use 'verify' to check integrity of stored message history. "
"Use 'export' to copy messages from SQLite to flat-file format, or 'import' to copy from flat-file to SQLite. "
"Both export and import merge with existing data (duplicates are skipped).")
CMD_ARGS(
{ "on|off", "Enable or disable showing chat history." },
{ "backend", "Show the name of the active database backend." },
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },
{ "export [<jid>]", "Export SQLite history to flat-file format. Optionally specify a JID to export only one contact." },

View File

@@ -6785,6 +6785,15 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
if (g_strcmp0(args[0], "backend") == 0) {
if (active_db_backend && active_db_backend->name) {
cons_show("Active database backend: %s", active_db_backend->name);
} else {
cons_show("No database backend is currently active.");
}
return TRUE;
}
if (g_strcmp0(args[0], "switch") == 0) {
jabber_conn_status_t conn_status = connection_get_status();
if (conn_status != JABBER_CONNECTED) {

View File

@@ -93,6 +93,7 @@ void db_sqlite_begin_transaction(void);
void db_sqlite_end_transaction(void);
void db_sqlite_rollback_transaction(void);
int db_sqlite_last_changes(void);
gboolean db_sqlite_is_open(void);
#endif
db_backend_t* db_backend_flatfile(void);

View File

@@ -30,10 +30,12 @@
#include "log.h"
#include "common.h"
#include "config/files.h"
#include "config/accounts.h"
#include "database.h"
#include "database_flatfile.h"
#include "config/preferences.h"
#include "ui/ui.h"
#include "xmpp/session.h"
#include "xmpp/xmpp.h"
#include "xmpp/message.h"
@@ -46,28 +48,69 @@
// Print progress to console every N messages during export/import
#define EXPORT_PROGRESS_INTERVAL 500
// Open SQLite if it isn't already (e.g. when flatfile is the active backend
// and sqlite was never initialised this session, or was closed by /history
// switch). Returns 1 if we opened it (caller must close), 0 if it was already
// open, -1 on failure.
static int
_ensure_sqlite_open(void)
{
if (db_sqlite_is_open())
return 0;
db_backend_t* sqlite_be = db_backend_sqlite();
if (!sqlite_be || !sqlite_be->init)
return -1;
const char* account_name = session_get_account_name();
if (!account_name)
return -1;
ProfAccount* account = accounts_get_account(account_name);
if (!account)
return -1;
gboolean ok = sqlite_be->init(account);
account_free(account);
return ok ? 1 : -1;
}
static void
_close_temp_sqlite(int opened)
{
if (opened != 1)
return;
db_backend_t* sqlite_be = db_backend_sqlite();
if (sqlite_be && sqlite_be->close)
sqlite_be->close();
}
// =========================================================================
// Dedup key: stanza_id if present, else hash of timestamp+from+body
// =========================================================================
static char*
// Returns a g_strdup'd key — callers free with g_free (gchar* convention).
//
// stanza_id is mixed into the hash but is NOT used as the sole key, since
// some clients (older Pidgin, Adium) reuse incremental ids per session and
// servers echo them back unchanged. Trusting stanza_id alone silently drops
// legitimate distinct messages on collision. By hashing id + timestamp +
// from_jid + body together we preserve id as a disambiguator without making
// it a single point of failure: true exact duplicates still dedup, but two
// messages that share an id with different content (or different timestamps)
// stay distinct.
static gchar*
_make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_jid, const char* body)
{
if (stanza_id && stanza_id[0] != '\0')
return g_strdup(stanza_id);
// Fallback for messages without stanza-id: SHA-256 over the FULL body,
// not the first 256 bytes. The previous prefix-only hash collided on
// legitimate near-identical templates (signatures, code blocks, paste-
// bombs) — second occurrence was silently dropped on import. Hashing
// whole body is microseconds of extra work per row.
GChecksum* sum = g_checksum_new(G_CHECKSUM_SHA256);
g_checksum_update(sum, (const guchar*)(stanza_id ? stanza_id : ""), -1);
g_checksum_update(sum, (const guchar*)"|", 1);
g_checksum_update(sum, (const guchar*)(timestamp ? timestamp : ""), -1);
g_checksum_update(sum, (const guchar*)"|", 1);
g_checksum_update(sum, (const guchar*)(from_jid ? from_jid : ""), -1);
g_checksum_update(sum, (const guchar*)"|", 1);
g_checksum_update(sum, (const guchar*)(body ? body : ""), -1);
char* key = g_strdup(g_checksum_get_string(sum));
gchar* key = g_strdup(g_checksum_get_string(sum));
g_checksum_free(sum);
return key;
}
@@ -75,14 +118,21 @@ _make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_j
// =========================================================================
// Helper: reverse ff_jid_to_dir (best-effort)
// '_at_' -> '@'
// Returns a g_strdup'd jid — callers free with g_free (gchar* convention).
// str_replace allocates via libc malloc, so we hand over to g_-allocated.
// =========================================================================
static char*
static gchar*
_dir_to_jid(const char* dirname)
{
if (!dirname)
return NULL;
return str_replace(dirname, "_at_", "@");
char* raw = str_replace(dirname, "_at_", "@");
if (!raw)
return NULL;
gchar* gres = g_strdup(raw);
free(raw);
return gres;
}
// =========================================================================
@@ -118,7 +168,7 @@ _ff_list_contacts(void)
while ((entry = g_dir_read_name(dir)) != NULL) {
auto_gchar gchar* full_path = g_strdup_printf("%s/%s/history.log", base_dir, entry);
if (g_file_test(full_path, G_FILE_TEST_IS_REGULAR)) {
char* jid = _dir_to_jid(entry);
gchar* jid = _dir_to_jid(entry);
if (jid) {
contacts = g_slist_prepend(contacts, jid);
}
@@ -168,20 +218,15 @@ _ff_read_all_lines(const char* contact_barejid)
while (1) {
gboolean truncated = FALSE;
char* buf = ff_readline(fp, &truncated);
auto_char char* buf = ff_readline(fp, &truncated);
if (!buf)
break;
if (truncated) {
free(buf);
if (truncated)
break;
}
if (buf[0] == '\0' || buf[0] == '#') {
free(buf);
if (buf[0] == '\0' || buf[0] == '#')
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
lines = g_slist_prepend(lines, pl);
}
@@ -234,7 +279,7 @@ _export_one_contact(const char* contact)
GSList* existing = _ff_read_all_lines(contact);
for (GSList* l = existing; l; l = l->next) {
ff_parsed_line_t* pl = l->data;
char* key = _make_dedup_key(pl->stanza_id, pl->timestamp_str, pl->from_jid, pl->message);
gchar* key = _make_dedup_key(pl->stanza_id, pl->timestamp_str, pl->from_jid, pl->message);
g_hash_table_add(seen_keys, key);
}
int existing_count = g_slist_length(existing);
@@ -330,7 +375,7 @@ _export_one_contact(const char* contact)
const char* body = msg->plain ? msg->plain : "";
const char* sid = msg->id ? msg->id : "";
char* key = _make_dedup_key(sid, ts, from_jid, body);
gchar* key = _make_dedup_key(sid, ts, from_jid, body);
if (g_hash_table_contains(seen_keys, key)) {
g_free(key);
contact_skipped++;
@@ -426,6 +471,15 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
return -1;
}
// SQLite must be open for db_sqlite_list_contacts/db_sqlite_get_all_chat
// to return rows. When the flatfile backend is active, sqlite is closed —
// queries silently return NULL and the export reports zero changes.
int sqlite_opened = _ensure_sqlite_open();
if (sqlite_opened < 0) {
cons_show_error("Export failed: could not open SQLite database.");
return -1;
}
// Determine contact list
GSList* contacts = NULL;
if (contact_jid) {
@@ -434,6 +488,7 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
contacts = db_sqlite_list_contacts();
if (!contacts) {
cons_show("No contacts found in SQLite database.");
_close_temp_sqlite(sqlite_opened);
return 0;
}
cons_show("Exporting %d contact(s) to flat-file format...", g_slist_length(contacts));
@@ -449,6 +504,7 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
}
g_slist_free_full(contacts, g_free);
_close_temp_sqlite(sqlite_opened);
return total_exported;
}
@@ -475,6 +531,12 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
return -1;
}
int sqlite_opened = _ensure_sqlite_open();
if (sqlite_opened < 0) {
cons_show_error("Import failed: could not open SQLite database.");
return -1;
}
// Determine contacts
GSList* contacts = NULL;
if (contact_jid) {
@@ -483,6 +545,7 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
contacts = _ff_list_contacts();
if (!contacts) {
cons_show("No flat-file history found to import.");
_close_temp_sqlite(sqlite_opened);
return 0;
}
}
@@ -509,7 +572,7 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
if (!msg)
continue;
auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup("");
char* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain);
gchar* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain);
g_hash_table_add(seen_keys, key);
}
g_slist_free_full(existing_msgs, (GDestroyNotify)message_free);
@@ -529,7 +592,7 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
continue;
auto_gchar gchar* ts = g_date_time_format_iso8601(pl->timestamp);
char* key = _make_dedup_key(pl->stanza_id, ts, pl->from_jid, pl->message);
gchar* key = _make_dedup_key(pl->stanza_id, ts, pl->from_jid, pl->message);
if (g_hash_table_contains(seen_keys, key)) {
g_free(key);
@@ -610,6 +673,7 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
}
g_slist_free_full(contacts, g_free);
_close_temp_sqlite(sqlite_opened);
return total_imported;
}

View File

@@ -54,7 +54,6 @@ ff_state_new(const char* filepath)
{
ff_contact_state_t* state = g_malloc0(sizeof(ff_contact_state_t));
state->filepath = g_strdup(filepath);
state->cursor_offset = -1;
state->archive_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
state->stanza_senders = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
return state;
@@ -145,7 +144,7 @@ _ff_maybe_index_line(ff_contact_state_t* state, const char* buf, off_t pos)
char* ts_str = g_strndup(buf, space - buf);
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
if (!dt) {
log_warning("flatfile: unparseable timestamp in %s at offset %ld: %s",
log_warning("flatfile: unparsable timestamp in %s at offset %ld: %s",
state->filepath, (long)pos, ts_str);
g_free(ts_str);
return;
@@ -206,6 +205,15 @@ _ff_state_build(ff_contact_state_t* state)
state->bom_len = ff_skip_bom(fp);
int file_version = ff_read_format_version(fp);
if (file_version == 0) {
log_warning("flatfile: %s has no format-version marker, expected %d",
state->filepath, FLATFILE_FORMAT_VERSION);
} else if (file_version != FLATFILE_FORMAT_VERSION) {
log_warning("flatfile: %s format-version %d does not match expected %d",
state->filepath, file_version, FLATFILE_FORMAT_VERSION);
}
state->n_entries = 0;
state->total_lines = 0;
@@ -294,7 +302,6 @@ ff_state_ensure_fresh(ff_contact_state_t* state)
if (st.st_ino == state->stamp.inode && st.st_size > state->stamp.size)
return _ff_state_extend(state);
state->cursor_offset = -1;
return _ff_state_build(state);
}
@@ -719,20 +726,28 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
return DB_RESPONSE_EMPTY;
if (state->total_lines == 0)
return DB_RESPONSE_EMPTY;
// Reset cursor for filtered (non-Page-Up) queries
if (start_time)
state->cursor_offset = -1;
// n_entries can be 0 even when total_lines > 0 if every line in the
// file failed _ff_maybe_index_line (e.g. unparsable timestamps).
// Subsequent backup logic dereferences entries[0] unconditionally,
// which would segfault on a NULL entries array.
if (state->n_entries == 0)
return DB_RESPONSE_EMPTY;
// Determine read byte-range using the sparse index
off_t read_from = state->bom_len;
off_t read_to = state->stamp.size;
// Use stored cursor for sequential Page Up (skip bisect)
if (!start_time && end_time && state->cursor_offset >= 0) {
read_to = state->cursor_offset;
} else if (end_time) {
// Upper-bound: find first index entry AFTER end_time
// Upper-bound via bisect on end_time. The previous cursor-based
// shortcut (use last batch's oldest file_offset as read_to) was
// both buggy and unnecessary: cursor was set to the oldest line in
// the read window, but pagination dropped the older portion of that
// window before returning to the caller. The next page-up therefore
// started reading from a point much earlier than what the user had
// actually seen, "jumping" past entire ranges of messages. Bisect
// on the index is O(log n_entries) — for typical 200-entry indexes
// it's a handful of comparisons, well below the per-line parse
// cost — so we now run it unconditionally.
if (end_time) {
GDateTime* edt = g_date_time_new_from_iso8601(end_time, NULL);
if (edt) {
gint64 end_epoch = g_date_time_to_unix(edt);
@@ -745,8 +760,8 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
else
hi = mid;
}
// lo = first entry with timestamp > end_epoch
// Use next entry's offset as read_to (+ 1 entry margin)
// lo = first entry with timestamp > end_epoch.
// Use next entry's offset as read_to (+ 1 entry margin).
if (lo + 1 < state->n_entries)
read_to = state->entries[lo + 1].byte_offset;
// else read_to stays at EOF
@@ -777,6 +792,15 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
read_from = backed;
}
// Defensive: an empty or inverted range means there is nothing to read.
// Reachable when start_time bisects past end_time, or when an end_time
// upper-bound shrinks read_to below the BOM-adjusted read_from.
if (read_from >= read_to) {
log_debug("flatfile: empty range [%ld, %ld) for %s",
(long)read_from, (long)read_to, contact_barejid);
return DB_RESPONSE_EMPTY;
}
// Open file and read lines in [read_from, read_to)
FILE* fp = fopen(state->filepath, "r");
if (!fp)
@@ -849,10 +873,6 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
if (!all_parsed)
return DB_RESPONSE_EMPTY;
// Update cursor: byte offset of oldest parsed message in this batch
ff_parsed_line_t* oldest = all_parsed->data;
state->cursor_offset = oldest->file_offset;
// Apply LMC corrections (if enabled) and build ProfMessage list
GSList* pre_result = NULL;
if (prefs_get_boolean(PREF_CORRECTION_ALLOW)) {

View File

@@ -24,10 +24,14 @@
// --- Constants ---
#define DIR_FLATLOG "flatlog"
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
#define DIR_FLATLOG "flatlog"
#define FLATFILE_FORMAT_VERSION 1
#define FF_VERSION_MARKER "format-version: "
#define FF_STRINGIFY_(x) #x
#define FF_STRINGIFY(x) FF_STRINGIFY_(x)
#define FLATFILE_HEADER "# cproof chat log — UTF-8, LF line endings, " FF_VERSION_MARKER FF_STRINGIFY(FLATFILE_FORMAT_VERSION) "\n"
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
// --- Shared global ---
@@ -64,8 +68,8 @@ typedef struct
// --- Metadata field prefixes (used in _ff_cache_line_ids and ff_parse_line) ---
#define FF_META_PREFIX_ID "id:"
#define FF_META_PREFIX_AID "aid:"
#define FF_META_PREFIX_ID "id:"
#define FF_META_PREFIX_AID "aid:"
typedef struct
{
@@ -88,7 +92,6 @@ typedef struct
size_t cap_entries;
size_t total_lines;
ff_file_stamp_t stamp;
off_t cursor_offset;
int bom_len;
GHashTable* archive_ids; // set: archive_id -> NULL (MAM dedup, O(1))
GHashTable* stanza_senders; // map: stanza_id -> from_jid (LMC validation, O(1))
@@ -126,6 +129,11 @@ char* ff_unescape_meta_value(const char* val);
// Skip UTF-8 BOM if present; returns 3 if skipped, 0 otherwise.
int ff_skip_bom(FILE* fp);
// Scan leading '#' comment lines for the format-version marker. Returns
// the version found, 0 if no marker is present, or -1 on read error.
// File position is rewound to the start of the comment block on entry.
int ff_read_format_version(FILE* fp);
char* ff_readline(FILE* fp, gboolean* truncated);
void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
const char* stanza_id, const char* archive_id, const char* replace_id,

View File

@@ -16,6 +16,7 @@
#include <sys/stat.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -338,6 +339,52 @@ ff_skip_bom(FILE* fp)
return 0;
}
// Format-version helper
// =========================================================================
//
// Scan leading '#' comment lines for the `format-version: <N>` marker
// (anywhere within the comment line). Stops at the first non-comment
// line or after FF_VERSION_SCAN_MAX comments. Restores fp to its
// position at entry, so callers can re-parse the header normally
// afterwards.
#define FF_VERSION_SCAN_MAX 16
int
ff_read_format_version(FILE* fp)
{
long start = ftell(fp);
if (start < 0)
return -1;
int version = 0;
size_t marker_len = strlen(FF_VERSION_MARKER);
for (int i = 0; i < FF_VERSION_SCAN_MAX; i++) {
gboolean truncated = FALSE;
char* line = ff_readline(fp, &truncated);
if (!line)
break;
if (line[0] != '#') {
free(line);
break;
}
char* found = strstr(line, FF_VERSION_MARKER);
if (found) {
char* num_start = found + marker_len;
char* endptr = NULL;
long v = strtol(num_start, &endptr, 10);
if (endptr != num_start && v > 0 && v <= INT_MAX)
version = (int)v;
free(line);
break;
}
free(line);
}
fseek(fp, start, SEEK_SET);
return version;
}
// Readline helper
// =========================================================================
//
@@ -421,11 +468,13 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
if (safe_rid) {
g_string_append_printf(meta, "|corrects:%s", safe_rid);
}
if (to_jid && strlen(to_jid) > 0) {
if (to_jid && strlen(to_jid) > 0
&& g_strcmp0(to_jid, "(null)") != 0) {
auto_gchar gchar* safe_to = ff_escape_meta_value(to_jid);
g_string_append_printf(meta, "|to:%s", safe_to);
}
if (to_resource && strlen(to_resource) > 0) {
if (to_resource && strlen(to_resource) > 0
&& g_strcmp0(to_resource, "(null)") != 0) {
auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource);
g_string_append_printf(meta, "|to_res:%s", safe_tores);
}
@@ -436,8 +485,11 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
// Build sender — escape ": " in the resource part to prevent
// the parser from splitting at the wrong point.
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
if (from_resource && strlen(from_resource) > 0) {
const gboolean from_jid_usable = from_jid && *from_jid
&& g_strcmp0(from_jid, "(null)") != 0;
GString* sender = g_string_new(from_jid_usable ? from_jid : "unknown");
if (from_resource && strlen(from_resource) > 0
&& g_strcmp0(from_resource, "(null)") != 0) {
// Escape backslash and colon-space in resource
GString* safe_res = g_string_sized_new(strlen(from_resource));
for (const char* p = from_resource; *p; p++) {

View File

@@ -77,8 +77,8 @@ _collect_contact_dirs(const gchar* const contact_barejid, GSList** issues)
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
} else {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
g_strdup("No log files found for this contact")));
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
g_strdup("No log files found for this contact")));
}
return contact_dirs;
}
@@ -117,9 +117,9 @@ _check_permissions(const char* filepath, const char* basename, GSList** issues)
if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
return;
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
st.st_mode & 0777)));
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
st.st_mode & 0777)));
}
// =========================================================================
@@ -144,8 +144,8 @@ _first_pass(FILE* fp, const char* basename,
{
if (ff_skip_bom(fp)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
}
char* buf = NULL;
@@ -172,8 +172,8 @@ _first_pass(FILE* fp, const char* basename,
const gchar* end;
if (!g_utf8_validate(buf, -1, &end)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
free(buf);
continue;
}
@@ -182,8 +182,8 @@ _first_pass(FILE* fp, const char* basename,
unsigned char ch = (unsigned char)buf[i];
if (ch < 0x20 && ch != '\t') {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Contains control character 0x%02x", ch)));
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Contains control character 0x%02x", ch)));
break;
}
}
@@ -191,8 +191,8 @@ _first_pass(FILE* fp, const char* basename,
ff_parsed_line_t* pl = ff_parse_line(buf);
if (!pl) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup("Unparsable line")));
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup("Unparsable line")));
free(buf);
continue;
}
@@ -202,8 +202,8 @@ _first_pass(FILE* fp, const char* basename,
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
}
if (prev_ts)
g_date_time_unref(prev_ts);
@@ -211,9 +211,13 @@ _first_pass(FILE* fp, const char* basename,
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
// Older clients (Pidgin, Adium, even early Profanity)
// sometimes reuse client-generated stanza-ids across
// distinct messages, so a collision is not a real
// integrity defect — log it for diagnostics but don't
// surface it through /history verify.
log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d",
pl->stanza_id, basename, lineno);
} else {
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
}
@@ -222,8 +226,8 @@ _first_pass(FILE* fp, const char* basename,
if (pl->archive_id && pl->archive_id[0] != '\0') {
if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
} else {
g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
}
@@ -237,13 +241,13 @@ _first_pass(FILE* fp, const char* basename,
if (has_crlf) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
}
if (is_empty) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File is empty (no message lines)")));
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File is empty (no message lines)")));
}
}
@@ -281,9 +285,9 @@ _lmc_pass(FILE* fp, const char* basename, GHashTable* all_stanza_ids, GSList** i
if (pl->replace_id && pl->replace_id[0] != '\0'
&& !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Broken correction reference: corrects:%s not found",
pl->replace_id)));
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Broken correction reference: corrects:%s not found",
pl->replace_id)));
}
ff_parsed_line_free(pl);
}
@@ -302,7 +306,15 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
return;
}
const char* basename = "history.log";
// Reconstruct the contact JID from the directory name so issue reports
// identify which contact the problem belongs to (every contact has its
// own history.log, so the bare filename is ambiguous).
auto_gchar gchar* dir_name = g_path_get_basename(cdir_path);
char* dir_unescaped = str_replace(dir_name, "_at_", "@");
auto_gchar gchar* basename_owned = g_strdup_printf("%s/history.log",
dir_unescaped ? dir_unescaped : dir_name);
free(dir_unescaped);
const char* basename = basename_owned;
_check_permissions(filepath, basename, issues);
@@ -344,8 +356,8 @@ ff_verify_integrity(const gchar* const contact_barejid)
if (!g_flatfile_account_jid) {
issues = g_slist_prepend(issues,
_issue_new(INTEGRITY_ERROR, "N/A", 0,
g_strdup("Flat-file backend not initialized")));
_issue_new(INTEGRITY_ERROR, "N/A", 0,
g_strdup("Flat-file backend not initialized")));
return issues;
}

View File

@@ -65,6 +65,12 @@ static gboolean _check_available_space_for_db_migration(char* path_to_db);
static const int latest_version = 2;
gboolean
db_sqlite_is_open(void)
{
return g_chatlog_database != NULL;
}
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
static void
_db_teardown(const char* ctx)
@@ -460,23 +466,36 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
sqlite3_finalize(stmt);
}
// Check timestamp ordering for a specific contact or all
// Check timestamp ordering, restricted to consecutive rows belonging
// to the same conversation pair so adjacency does not span unrelated
// contacts. Adjacent rows by id from different conversations are
// legitimately out-of-order and should not be reported.
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid) {
auto_sqlite char* query = NULL;
if (contact_barejid) {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"WHERE A.`timestamp` > B.`timestamp` "
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50",
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
} else {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"WHERE A.`timestamp` > B.`timestamp` "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50");
}
@@ -484,15 +503,19 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id_a = sqlite3_column_int(stmt, 0);
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
int id_b = sqlite3_column_int(stmt, 2);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
const char* from_a = (const char*)sqlite3_column_text(stmt, 2);
const char* to_a = (const char*)sqlite3_column_text(stmt, 3);
int id_b = sqlite3_column_int(stmt, 4);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 5);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_WARNING;
issue->file = g_strdup("chatlog.db");
issue->line = id_a;
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
issue->message = g_strdup_printf("Timestamp out of order in %s↔%s: row %d (%s) > row %d (%s)",
from_a ? from_a : "?", to_a ? to_a : "?",
id_a, ts_a ? ts_a : "NULL",
id_b, ts_b ? ts_b : "NULL");
issues = g_slist_append(issues, issue);
}
sqlite3_finalize(stmt);
@@ -500,7 +523,7 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
// Check broken LMC references
auto_sqlite char* lmc_query = sqlite3_mprintf(
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` FROM `ChatLogs` A "
"WHERE A.`replaces_db_id` IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
"LIMIT 50");
@@ -509,12 +532,15 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
int replaces_id = sqlite3_column_int(stmt, 1);
const char* from = (const char*)sqlite3_column_text(stmt, 2);
const char* to = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup("chatlog.db");
issue->line = id;
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
issue->message = g_strdup_printf("Broken LMC reference in %s↔%s: row %d references non-existent row %d",
from ? from : "?", to ? to : "?",
id, replaces_id);
issues = g_slist_append(issues, issue);
}

View File

@@ -52,6 +52,7 @@
#include "config/theme.h"
#include "config/preferences.h"
#include "database.h"
#include "ui/ui.h"
#include "ui/statusbar.h"
#include "ui/inputwin.h"
@@ -83,6 +84,7 @@ static WINDOW* statusbar_win;
void _get_range_bounds(int* start, int* end, gboolean is_static);
static int _status_bar_draw_time(int pos);
static int _status_bar_draw_maintext(int pos);
static int _status_bar_draw_dbbackend(int pos);
static int _status_bar_draw_bracket(gboolean current, int pos, const char* ch);
static int _status_bar_draw_extended_tabs(int pos, gboolean prefix, int start, int end, gboolean is_static);
static int _status_bar_draw_tab(StatusBarTab* tab, int pos, int num, gboolean include_brackets);
@@ -313,6 +315,7 @@ status_bar_draw(void)
pos = _status_bar_draw_time(pos);
pos = _status_bar_draw_maintext(pos);
pos = _status_bar_draw_dbbackend(pos);
if (max_tabs != 0)
pos = _status_bar_draw_tabs(pos);
@@ -603,6 +606,17 @@ _status_bar_draw_maintext(int pos)
return pos;
}
static int
_status_bar_draw_dbbackend(int pos)
{
if (!active_db_backend || !active_db_backend->name)
return pos;
auto_gchar gchar* label = g_strdup_printf(" [%s]", active_db_backend->name);
mvwprintw(statusbar_win, 0, pos, "%s", label);
return pos + utf8_display_len(label);
}
static void
_destroy_tab(StatusBarTab* tab)
{

View File

@@ -111,6 +111,17 @@ jid_create(const gchar* const str)
Jid*
jid_create_from_bare_and_resource(const char* const barejid, const char* const resource)
{
// NULL, empty, or the literal "(null)" (legacy artefact from earlier
// builds where g_strdup_printf("%s", NULL) leaked the glibc placeholder
// into stored data) all mean "no value" for either part. With no usable
// bare jid there is nothing to construct; with no usable resource we
// return the bare jid alone.
if (!barejid || !*barejid || g_strcmp0(barejid, "(null)") == 0) {
return NULL;
}
if (!resource || !*resource || g_strcmp0(resource, "(null)") == 0) {
return jid_create(barejid);
}
auto_char char* jid = create_fulljid(barejid, resource);
return jid_create(jid);
}

View File

@@ -7,7 +7,7 @@
* bench_failure_modes.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* F1F15 failure-injection tests. Each test crafts a deliberately corrupt
* 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)
@@ -21,6 +21,8 @@
* 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
@@ -46,6 +48,7 @@
#include "bench_common.h"
#include "bench_csv.h"
#include "config/account.h"
#include "database_flatfile.h"
#define ACCOUNT_JID "fail@bench.example"
@@ -578,6 +581,224 @@ test_F15(fail_ctx_t* ctx)
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
@@ -617,6 +838,8 @@ main(int argc, char** argv)
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);

View File

@@ -458,7 +458,7 @@ void
test_ff_parse_line_comment(void** state)
{
assert_null(ff_parse_line("# this is a comment"));
assert_null(ff_parse_line("# profanity chat log — UTF-8, LF line endings"));
assert_null(ff_parse_line("# cproof chat log — UTF-8, LF line endings"));
}
void

View File

@@ -893,7 +893,7 @@ test_stress_mixed_types_and_encodings(void** state)
/* Parse and verify type/enc distribution */
fp = fopen(tmppath, "r");
assert_non_null(fp);
int counts[3] = { 0 }; /* chat, muc, mucpm */
int counts[3] = { 0 }; /* chat, muc, mucpm */
int enc_counts[5] = { 0 }; /* none, omemo, otr, pgp, ox */
int parsed = 0;
@@ -911,15 +911,23 @@ test_stress_mixed_types_and_encodings(void** state)
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->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]++;
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);
}