From c24879bdc03e2747ac9c6cbcf40e18631f6c7c4f Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sat, 21 Feb 2026 16:15:38 +0300 Subject: [PATCH] fix: harden flatfile backend, make SQLite optional Build system: - Add --without-sqlite configure flag (AM_CONDITIONAL BUILD_SQLITE) - Guard database_sqlite.c with HAVE_SQLITE in Makefile.am, database.c, database.h and stub_database.c - Fall back to flatfile when SQLite not compiled in Security (database_flatfile.c): - MAM dedup: skip incoming messages with duplicate stanza-id (archive_id) - LMC sender validation: reject corrections from mismatched JIDs - Add flock() advisory locking to prevent interleaved writes from concurrent instances - Check fprintf return when writing file header Autocomplete (cmd_ac.c): - Add 'flatfile' to /privacy logging autocomplete - Add dedicated /history autocomplete with on/off/verify (was boolean-only) Code quality: - Fix fwrite return type: size_t not ssize_t (database_flatfile_parser.c) - Fix mixed allocators in ff_readline: use malloc() consistently for overlength-line fallback instead of g_strdup() - Fix index alignment in _ff_state_extend: use local counter so index step doesn't depend on total_lines from initial build Documentation: - Fix page: correct directory structure (history.log not per-day files) - Fix page: add aid:{archive_id} to line format example - Fix page: missing newline before .SH BUGS --- Makefile.am | 7 +- configure.ac | 14 ++- docs/profanity.1 | 7 +- src/command/cmd_ac.c | 14 ++- src/database.c | 6 ++ src/database.h | 2 + src/database_flatfile.c | 132 ++++++++++++++++++++++- src/database_flatfile_parser.c | 14 ++- tests/unittests/database/stub_database.c | 3 + 9 files changed, 182 insertions(+), 17 deletions(-) diff --git a/Makefile.am b/Makefile.am index 6d4acc9e..f3b5c104 100644 --- a/Makefile.am +++ b/Makefile.am @@ -3,7 +3,6 @@ core_sources = \ src/log.c src/common.c \ src/chatlog.c src/chatlog.h \ src/database.h src/database.c \ - src/database_sqlite.c \ src/database_flatfile.c src/database_flatfile.h \ src/database_flatfile_parser.c \ src/database_flatfile_verify.c \ @@ -261,6 +260,12 @@ core_sources += $(omemo_sources) unittest_sources += $(omemo_unittest_sources) endif +sqlite_sources = src/database_sqlite.c + +if BUILD_SQLITE +core_sources += $(sqlite_sources) +endif + all_c_sources = $(core_sources) $(unittest_sources) \ $(pgp_sources) $(pgp_unittest_sources) \ $(otr4_sources) $(otr_unittest_sources) \ diff --git a/configure.ac b/configure.ac index c79774e0..ba7110eb 100644 --- a/configure.ac +++ b/configure.ac @@ -94,8 +94,17 @@ PKG_CHECK_MODULES([curl], [libcurl >= 7.62.0], [], [AC_CHECK_LIB([curl], [main], [], [AC_MSG_ERROR([libcurl 7.62.0 or higher is required])])]) -PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0], [], - [AC_MSG_ERROR([sqlite3 3.22.0 or higher is required])]) +### sqlite (optional — can be disabled with --without-sqlite) +AC_ARG_WITH([sqlite], + [AS_HELP_STRING([--without-sqlite], [build without SQLite support (flat-file backend only)])], + [], [with_sqlite=yes]) + +AS_IF([test "x$with_sqlite" != "xno"], + [PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0], + [AC_DEFINE([HAVE_SQLITE], [1], [SQLite support])], + [AC_MSG_ERROR([sqlite3 3.22.0 or higher is required (use --without-sqlite to disable)])])], + [AC_MSG_NOTICE([Building without SQLite — flat-file backend only])]) +AM_CONDITIONAL([BUILD_SQLITE], [test "x$with_sqlite" != "xno"]) ACX_PTHREAD([], [AC_MSG_ERROR([pthread is required])]) AS_IF([test "x$PTHREAD_CC" != x], [ CC="$PTHREAD_CC" ]) @@ -416,6 +425,7 @@ AC_OUTPUT AC_MSG_NOTICE([Summary of build options: PLATFORM : $target_os PACKAGE_STATUS : $PACKAGE_STATUS +SQLite support : $with_sqlite GTK_VERSION : $GTK_VERSION LIBS : $LIBS Install themes : $THEMES_INSTALL diff --git a/docs/profanity.1 b/docs/profanity.1 index f8dd0a08..934e4d64 100755 --- a/docs/profanity.1 +++ b/docs/profanity.1 @@ -215,17 +215,18 @@ Or use the command: Flat-file logs are stored under .IR $XDG_DATA_HOME/profanity/flatlog/ , organized as -.IR {account_jid}/{contact_jid}/{YYYY_MM_DD}.log . +.IR {account_jid}/{contact_jid}/history.log . .PP Each line has the format: .br .EX -{ISO8601} [{type}|{enc}|id:{id}|corrects:{id}] {sender}: {message} +{ISO8601} [{type}|{enc}|id:{id}|aid:{archive_id}|corrects:{id}] {sender}: {message} .EE .PP Use .B /history verify -to check integrity of stored history (both SQLite and flat-file)..SH BUGS +to check integrity of stored history (both SQLite and flat-file). +.SH BUGS Bugs can either be reported by raising an issue at the Github issue tracker: .br .PP diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index facbbd30..bebac5de 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -275,6 +275,7 @@ static Autocomplete logging_ac; static Autocomplete logging_group_ac; static Autocomplete privacy_ac; static Autocomplete privacy_log_ac; +static Autocomplete history_ac; static Autocomplete color_ac; static Autocomplete correction_ac; static Autocomplete avatar_ac; @@ -429,6 +430,7 @@ static Autocomplete* all_acs[] = { &logging_group_ac, &privacy_ac, &privacy_log_ac, + &history_ac, &color_ac, &correction_ac, &avatar_ac, @@ -1138,6 +1140,11 @@ cmd_ac_init(void) autocomplete_add(privacy_log_ac, "on"); autocomplete_add(privacy_log_ac, "off"); autocomplete_add(privacy_log_ac, "redact"); + autocomplete_add(privacy_log_ac, "flatfile"); + + autocomplete_add(history_ac, "on"); + autocomplete_add(history_ac, "off"); + autocomplete_add(history_ac, "verify"); autocomplete_add(logging_group_ac, "on"); autocomplete_add(logging_group_ac, "off"); @@ -1776,7 +1783,7 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ // autocomplete boolean settings gchar* boolean_choices[] = { "/beep", "/states", "/outtype", "/flash", "/splash", - "/history", "/vercheck", "/privileges", "/wrap", + "/vercheck", "/privileges", "/wrap", "/carbons", "/slashguard", "/mam", "/silence" }; for (int i = 0; i < ARRAY_SIZE(boolean_choices); i++) { @@ -1786,6 +1793,11 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ } } + result = autocomplete_param_with_ac(input, "/history", history_ac, TRUE, previous); + if (result) { + return result; + } + // autocomplete nickname in chat rooms if (window->type == WIN_MUC) { ProfMucWin* mucwin = (ProfMucWin*)window; diff --git a/src/database.c b/src/database.c index d85471da..46689484 100644 --- a/src/database.c +++ b/src/database.c @@ -41,6 +41,7 @@ #include "log.h" #include "common.h" +#include "config.h" #include "database.h" #include "config/preferences.h" #include "ui/ui.h" @@ -69,8 +70,13 @@ log_database_init(ProfAccount* account) active_db_backend = db_backend_flatfile(); log_info("Using flat-file database backend"); } else { +#ifdef HAVE_SQLITE active_db_backend = db_backend_sqlite(); log_info("Using SQLite database backend"); +#else + log_info("SQLite not compiled in, falling back to flat-file backend"); + active_db_backend = db_backend_flatfile(); +#endif } if (!active_db_backend) { diff --git a/src/database.h b/src/database.h index 34b3c515..1a9e0bf9 100644 --- a/src/database.h +++ b/src/database.h @@ -85,7 +85,9 @@ typedef struct db_backend_t extern db_backend_t* active_db_backend; // Backend registry +#ifdef HAVE_SQLITE db_backend_t* db_backend_sqlite(void); +#endif db_backend_t* db_backend_flatfile(void); // Public API (dispatches to active_db_backend) diff --git a/src/database_flatfile.c b/src/database_flatfile.c index e8b36316..b8e10043 100644 --- a/src/database_flatfile.c +++ b/src/database_flatfile.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -180,7 +181,9 @@ _ff_state_extend(ff_contact_state_t* state) } } - size_t msg_count = state->total_lines; + // Use a local counter for new messages so index step alignment + // doesn't depend on the total from the initial build. + size_t new_count = 0; while (1) { off_t pos = ftell(fp); @@ -200,7 +203,7 @@ _ff_state_extend(ff_contact_state_t* state) state->total_lines++; - if (msg_count % FF_INDEX_STEP == 0) { + if (new_count % FF_INDEX_STEP == 0) { char* space = strchr(buf, ' '); if (space) { char* ts_str = g_strndup(buf, space - buf); @@ -219,7 +222,7 @@ _ff_state_extend(ff_contact_state_t* state) g_free(ts_str); } } - msg_count++; + new_count++; free(buf); } @@ -390,8 +393,17 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id, return; } + // Advisory lock to prevent interleaved writes from concurrent instances + if (flock(fd, LOCK_EX) != 0) { + log_warning("flatfile: flock(%s) failed (errno=%d: %s), writing anyway", + log_path, errno, strerror(errno)); + } + if (is_new) { - fprintf(fp, "%s", FLATFILE_HEADER); + if (fprintf(fp, "%s", FLATFILE_HEADER) < 0) { + log_error("flatfile: failed to write header to %s (errno=%d)", + log_path, errno); + } } ff_write_line(fp, date_fmt, type, ff_get_message_enc_str(enc), @@ -399,6 +411,7 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id, from_barejid, from_resource, effective_msg); fflush(fp); + // fclose also releases the flock fclose(fp); g_free(effective_msg); } @@ -431,7 +444,7 @@ _flatfile_init(ProfAccount* account) g_hash_table_destroy(g_contact_states); } g_contact_states = g_hash_table_new_full(g_str_hash, g_str_equal, - g_free, (GDestroyNotify)ff_state_free); + g_free, (GDestroyNotify)ff_state_free); log_info("Initialized flat-file database backend: %s", base_dir); return TRUE; @@ -453,6 +466,82 @@ _flatfile_close(void) // Backend callbacks: add message // ========================================================================= +// ========================================================================= +// Incoming message validation helpers +// ========================================================================= + +// Search log file for a line containing aid:{archive_id}, return TRUE if found. +static gboolean +_ff_has_archive_id(const char* log_path, const char* archive_id) +{ + if (!log_path || !archive_id || strlen(archive_id) == 0) + return FALSE; + + FILE* fp = fopen(log_path, "r"); + if (!fp) + return FALSE; + + auto_gchar gchar* needle = g_strdup_printf("aid:%s", archive_id); + gboolean found = FALSE; + + while (!found) { + gboolean trunc = FALSE; + char* line = ff_readline(fp, &trunc); + if (!line) + break; + if (trunc) { + free(line); + break; + } + if (strstr(line, needle)) { + found = TRUE; + } + free(line); + } + + fclose(fp); + return found; +} + +// Search log file for a line with stanza id matching replace_id, return the +// from_jid of the original message (caller must g_free). Returns NULL if not found. +static char* +_ff_find_original_sender(const char* log_path, const char* replace_id) +{ + if (!log_path || !replace_id || strlen(replace_id) == 0) + return NULL; + + FILE* fp = fopen(log_path, "r"); + if (!fp) + return NULL; + + auto_gchar gchar* needle = g_strdup_printf("id:%s", replace_id); + char* sender = NULL; + + while (!sender) { + gboolean trunc = FALSE; + char* line = ff_readline(fp, &trunc); + if (!line) + break; + if (trunc) { + free(line); + break; + } + if (strstr(line, needle)) { + ff_parsed_line_t* pl = ff_parse_line(line); + if (pl && pl->stanza_id && g_strcmp0(pl->stanza_id, replace_id) == 0) { + sender = g_strdup(pl->from_jid); + } + if (pl) + ff_parsed_line_free(pl); + } + free(line); + } + + fclose(fp); + return sender; +} + static void _flatfile_add_incoming(ProfMessage* message) { @@ -462,6 +551,39 @@ _flatfile_add_incoming(ProfMessage* message) const Jid* to_jid = message->to_jid ? message->to_jid : connection_get_jid(); const char* type = ff_get_message_type_str(message->type); + // Determine contact JID for log path (same logic as _ff_add_message) + const char* contact = NULL; + const Jid* myjid = connection_get_jid(); + if (myjid && myjid->barejid && g_strcmp0(message->from_jid->barejid, myjid->barejid) == 0) { + contact = to_jid->barejid; + } else { + contact = message->from_jid->barejid; + } + auto_gchar gchar* log_path = ff_get_log_path(contact); + + // MAM dedup: skip if this stanza-id already exists in the log (XEP-0313 replay) + if (message->stanzaid && !message->is_mam && log_path) { + if (_ff_has_archive_id(log_path, message->stanzaid)) { + log_error("flatfile: duplicate stanza-id '%s' from %s, skipping", + message->stanzaid, message->from_jid->barejid); + cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", + message->from_jid->fulljid); + return; + } + } + + // LMC sender validation: verify the correction comes from the original sender + if (message->replace_id && log_path) { + auto_gchar gchar* original_sender = _ff_find_original_sender(log_path, message->replace_id); + if (original_sender && g_strcmp0(original_sender, message->from_jid->barejid) != 0) { + log_error("flatfile: LMC sender mismatch — corrected msg sender: %s, original: %s, replace-id: %s", + message->from_jid->barejid, original_sender, message->replace_id); + cons_show_error("%s sent a message correction with mismatched sender. See log for details.", + message->from_jid->barejid); + return; + } + } + _ff_add_message(type, message->id, message->stanzaid, message->replace_id, message->from_jid->barejid, message->from_jid->resourcepart, to_jid->barejid, message->plain, diff --git a/src/database_flatfile_parser.c b/src/database_flatfile_parser.c index 12a553c5..56d6b13e 100644 --- a/src/database_flatfile_parser.c +++ b/src/database_flatfile_parser.c @@ -369,8 +369,12 @@ ff_readline(FILE* fp, gboolean* truncated) } if (truncated) *truncated = FALSE; - // Return empty string so caller's loop continues (parse will reject it) - return g_strdup(""); + // Return empty string so caller's loop continues (parse will reject it). + // Use malloc() so callers can always use free() consistently. + char* empty = malloc(1); + if (empty) + empty[0] = '\0'; + return empty; } if (truncated) *truncated = FALSE; @@ -450,9 +454,9 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc timestamp, meta->str, sender->str, safe_msg); size_t to_write = full_line->len; - ssize_t written = fwrite(full_line->str, 1, to_write, fp); - if (written != (ssize_t)to_write) { - log_error("flatfile: partial write (%zd/%zu)", written, to_write); + size_t written = fwrite(full_line->str, 1, to_write, fp); + if (written != to_write) { + log_error("flatfile: partial write (%zu/%zu)", written, to_write); } g_string_free(full_line, TRUE); diff --git a/tests/unittests/database/stub_database.c b/tests/unittests/database/stub_database.c index 72bbe96a..9d6f72df 100644 --- a/tests/unittests/database/stub_database.c +++ b/tests/unittests/database/stub_database.c @@ -23,6 +23,7 @@ #include #include "prof_cmocka.h" +#include "config.h" #include "database.h" db_backend_t* active_db_backend = NULL; @@ -66,11 +67,13 @@ integrity_issue_free(integrity_issue_t* issue) g_free(issue); } } +#ifdef HAVE_SQLITE db_backend_t* db_backend_sqlite(void) { return NULL; } +#endif db_backend_t* db_backend_flatfile(void) {