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
This commit is contained in:
2026-02-21 16:15:38 +03:00
parent 16ae7fb85c
commit 23723376c6
9 changed files with 182 additions and 17 deletions

View File

@@ -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) \

View File

@@ -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" ])
@@ -448,6 +457,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

View File

@@ -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

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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)

View File

@@ -33,6 +33,7 @@
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/file.h>
#include <glib.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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,

View File

@@ -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);

View File

@@ -23,6 +23,7 @@
#include <glib.h>
#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)
{