diff --git a/Makefile.am b/Makefile.am
index 7a9911a1..6d4acc9e 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -4,7 +4,9 @@ core_sources = \
src/chatlog.c src/chatlog.h \
src/database.h src/database.c \
src/database_sqlite.c \
- src/database_flatfile.c \
+ src/database_flatfile.c src/database_flatfile.h \
+ src/database_flatfile_parser.c \
+ src/database_flatfile_verify.c \
src/log.h src/profanity.c src/common.h \
src/profanity.h src/xmpp/chat_session.c \
src/xmpp/chat_session.h src/xmpp/muc.c src/xmpp/muc.h src/xmpp/jid.h src/xmpp/jid.c \
diff --git a/src/database_flatfile.c b/src/database_flatfile.c
index decc5172..b78869c9 100644
--- a/src/database_flatfile.c
+++ b/src/database_flatfile.c
@@ -19,15 +19,19 @@
* You should have received a copy of the GNU General Public License
* along with Profanity. If not, see .
*
+ * Flat-file database backend: init/close, message write, message read,
+ * LMC correction logic, and the backend vtable.
+ *
+ * Parser and escape helpers are in database_flatfile_parser.c.
+ * Integrity verification is in database_flatfile_verify.c.
+ * Shared types and prototypes are in database_flatfile.h.
*/
#include "config.h"
-#include
#include
#include
#include
-#include
#include
#include
#include
@@ -37,742 +41,21 @@
#include "common.h"
#include "config/files.h"
#include "database.h"
+#include "database_flatfile.h"
#include "config/preferences.h"
#include "ui/ui.h"
#include "xmpp/xmpp.h"
#include "xmpp/message.h"
-#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 */
+// =========================================================================
+// Shared global — account JID stored during init for path construction
+// =========================================================================
-// Account JID stored during init for path construction
-static char* g_flatfile_account_jid = NULL;
+char* g_flatfile_account_jid = NULL;
-// --- Type conversion helpers (shared with sqlite backend) ---
-
-static const char*
-_ff_get_message_type_str(prof_msg_type_t type)
-{
- switch (type) {
- case PROF_MSG_TYPE_CHAT:
- return "chat";
- case PROF_MSG_TYPE_MUC:
- return "muc";
- case PROF_MSG_TYPE_MUCPM:
- return "mucpm";
- case PROF_MSG_TYPE_UNINITIALIZED:
- return "chat";
- }
- return "chat";
-}
-
-static prof_msg_type_t
-_ff_get_message_type_type(const char* const type)
-{
- if (g_strcmp0(type, "chat") == 0) {
- return PROF_MSG_TYPE_CHAT;
- } else if (g_strcmp0(type, "muc") == 0) {
- return PROF_MSG_TYPE_MUC;
- } else if (g_strcmp0(type, "mucpm") == 0) {
- return PROF_MSG_TYPE_MUCPM;
- }
- return PROF_MSG_TYPE_CHAT;
-}
-
-static const char*
-_ff_get_message_enc_str(prof_enc_t enc)
-{
- switch (enc) {
- case PROF_MSG_ENC_OX:
- return "ox";
- case PROF_MSG_ENC_PGP:
- return "pgp";
- case PROF_MSG_ENC_OTR:
- return "otr";
- case PROF_MSG_ENC_OMEMO:
- return "omemo";
- case PROF_MSG_ENC_NONE:
- return "none";
- }
- return "none";
-}
-
-static prof_enc_t
-_ff_get_message_enc_type(const char* const encstr)
-{
- if (g_strcmp0(encstr, "ox") == 0) {
- return PROF_MSG_ENC_OX;
- } else if (g_strcmp0(encstr, "pgp") == 0) {
- return PROF_MSG_ENC_PGP;
- } else if (g_strcmp0(encstr, "otr") == 0) {
- return PROF_MSG_ENC_OTR;
- } else if (g_strcmp0(encstr, "omemo") == 0) {
- return PROF_MSG_ENC_OMEMO;
- }
- return PROF_MSG_ENC_NONE;
-}
-
-// --- Path helpers ---
-
-// Sanitise a JID for use as a directory name.
-// 1. Replace '@' with '_at_'
-// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
-// This prevents a malicious federated JID like "../../../tmp/pwned" from
-// escaping the log directory tree.
-static char*
-_ff_jid_to_dir(const char* jid)
-{
- if (!jid || jid[0] == '\0')
- return NULL;
-
- // Reject embedded null bytes (strlen already stops, but be explicit)
- // Replace '@' first
- char* step1 = str_replace(jid, "@", "_at_");
- if (!step1)
- return NULL;
-
- // Replace '/' and '\\' with '_' to prevent path traversal
- GString* out = g_string_sized_new(strlen(step1));
- for (const char* p = step1; *p; p++) {
- if (*p == '/' || *p == '\\') {
- g_string_append_c(out, '_');
- } else {
- g_string_append_c(out, *p);
- }
- }
- free(step1);
-
- // Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
- char* result = g_string_free(out, FALSE);
- char* dotdot;
- while ((dotdot = strstr(result, "..")) != NULL) {
- dotdot[0] = '_';
- dotdot[1] = '_';
- }
-
- // Reject empty result
- if (result[0] == '\0') {
- g_free(result);
- return NULL;
- }
- return result;
-}
-
-// Get the base directory for a contact's logs:
-// ~/.local/share/profanity/flatlog/{my_jid_dir}/{contact_jid_dir}/
-static char*
-_ff_get_contact_dir(const char* contact_barejid)
-{
- if (!g_flatfile_account_jid || !contact_barejid)
- return NULL;
-
- auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
- auto_gchar gchar* my_dir = _ff_jid_to_dir(g_flatfile_account_jid);
- auto_gchar gchar* contact_dir = _ff_jid_to_dir(contact_barejid);
-
- char* result = g_strdup_printf("%s/%s/%s", data_path, my_dir, contact_dir);
- return result;
-}
-
-// Get the log file path for a contact on a specific date:
-// {contact_dir}/{YYYY_MM_DD}.log
-static char*
-_ff_get_log_path(const char* contact_barejid, GDateTime* dt)
-{
- auto_gchar gchar* contact_dir = _ff_get_contact_dir(contact_barejid);
- if (!contact_dir)
- return NULL;
-
- auto_gchar gchar* date_str = g_date_time_format(dt, "%Y_%m_%d");
- char* result = g_strdup_printf("%s/%s.log", contact_dir, date_str);
- return result;
-}
-
-// Ensure the directory exists, create if needed.
-// Refuses to follow symlinks at the final component.
-static gboolean
-_ff_ensure_dir(const char* path)
-{
- if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
- // Verify it's not a symlink
- struct stat st;
- if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
- log_error("flatfile: directory path is a symlink, refusing: %s", path);
- return FALSE;
- }
- return TRUE;
- }
- if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
- log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
- return FALSE;
- }
- return TRUE;
-}
-
-// --- Escape / unescape helpers ---
-//
-// Message text and metadata values from remote peers can contain arbitrary
-// characters including newlines, pipes and brackets. Without escaping, a
-// crafted message could inject fake log lines (log injection / format
-// injection). We escape on write and unescape on read.
-
-// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal
-static char*
-_ff_escape_message(const char* text)
-{
- if (!text)
- return g_strdup("");
- GString* out = g_string_sized_new(strlen(text));
- for (const char* p = text; *p; p++) {
- switch (*p) {
- case '\\':
- g_string_append(out, "\\\\");
- break;
- case '\n':
- g_string_append(out, "\\n");
- break;
- case '\r':
- g_string_append(out, "\\r");
- break;
- default:
- g_string_append_c(out, *p);
- break;
- }
- }
- return g_string_free(out, FALSE);
-}
-
-// Unescape message body: \\ -> \, \n -> newline, \r -> CR
-static char*
-_ff_unescape_message(const char* text)
-{
- if (!text)
- return g_strdup("");
- GString* out = g_string_sized_new(strlen(text));
- for (const char* p = text; *p; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++;
- switch (*p) {
- case '\\':
- g_string_append_c(out, '\\');
- break;
- case 'n':
- g_string_append_c(out, '\n');
- break;
- case 'r':
- g_string_append_c(out, '\r');
- break;
- default:
- g_string_append_c(out, '\\');
- g_string_append_c(out, *p);
- break;
- }
- } else {
- g_string_append_c(out, *p);
- }
- }
- return g_string_free(out, FALSE);
-}
-
-// Escape metadata value (stanza_id, archive_id, replace_id):
-// these come from remote servers and may contain |, ], \, newlines.
-static char*
-_ff_escape_meta_value(const char* val)
-{
- if (!val || strlen(val) == 0)
- return NULL;
- GString* out = g_string_sized_new(strlen(val));
- for (const char* p = val; *p; p++) {
- switch (*p) {
- case '|':
- g_string_append(out, "\\|");
- break;
- case ']':
- g_string_append(out, "\\]");
- break;
- case '\\':
- g_string_append(out, "\\\\");
- break;
- case '\n':
- g_string_append(out, "\\n");
- break;
- case '\r':
- g_string_append(out, "\\r");
- break;
- default:
- g_string_append_c(out, *p);
- break;
- }
- }
- return g_string_free(out, FALSE);
-}
-
-// Unescape metadata value
-static char*
-_ff_unescape_meta_value(const char* val)
-{
- if (!val)
- return NULL;
- GString* out = g_string_sized_new(strlen(val));
- for (const char* p = val; *p; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++;
- switch (*p) {
- case '|':
- g_string_append_c(out, '|');
- break;
- case ']':
- g_string_append_c(out, ']');
- break;
- case '\\':
- g_string_append_c(out, '\\');
- break;
- case 'n':
- g_string_append_c(out, '\n');
- break;
- case 'r':
- g_string_append_c(out, '\r');
- break;
- default:
- g_string_append_c(out, '\\');
- g_string_append_c(out, *p);
- break;
- }
- } else {
- g_string_append_c(out, *p);
- }
- }
- return g_string_free(out, FALSE);
-}
-
-// --- Readline helper ---
-//
-// POSIX getline() for dynamic-length lines. Returns line without trailing
-// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
-// newline at EOF (partial write detection).
-static char*
-_ff_readline(FILE* fp, gboolean* truncated)
-{
- char* line = NULL;
- size_t cap = 0;
- ssize_t nread = getline(&line, &cap, fp);
- if (nread == -1) {
- free(line);
- return NULL;
- }
- // Guard against pathological lines that could exhaust memory.
- // getline() already allocated, so we free and skip if too long.
- if (nread > FF_MAX_LINE_LEN) {
- log_error("flatfile: line too long (%zd bytes), skipping", nread);
- // Check newline termination before freeing
- gboolean had_newline = (nread > 0 && line[nread - 1] == '\n');
- free(line);
- // Skip to next newline if the overlength line wasn't newline-terminated
- if (!had_newline) {
- int ch;
- while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
- }
- if (truncated)
- *truncated = FALSE;
- // Return empty string so caller's loop continues (parse will reject it)
- return g_strdup("");
- }
- if (truncated)
- *truncated = FALSE;
- if (nread > 0 && line[nread - 1] == '\n') {
- line[--nread] = '\0';
- } else if (feof(fp)) {
- // Line without trailing newline at EOF — likely a partial write
- if (truncated)
- *truncated = TRUE;
- }
- return line;
-}
-
-// --- Line format ---
-//
-// Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message}
-//
-// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r
-// Metadata values are escaped: |, ], \\, newline, CR
-// Lines starting with '#' are comments.
-// Empty lines are skipped.
-
-static 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,
- const char* from_jid, const char* from_resource, const char* message_text)
-{
- // Escape metadata values from remote peers
- auto_gchar gchar* safe_sid = _ff_escape_meta_value(stanza_id);
- auto_gchar gchar* safe_aid = _ff_escape_meta_value(archive_id);
- auto_gchar gchar* safe_rid = _ff_escape_meta_value(replace_id);
-
- // Build metadata section: [type|enc|id:...|aid:...|corrects:...]
- GString* meta = g_string_new("[");
- g_string_append(meta, type ? type : "chat");
- g_string_append_c(meta, '|');
- g_string_append(meta, enc ? enc : "none");
- if (safe_sid) {
- g_string_append_printf(meta, "|id:%s", safe_sid);
- }
- if (safe_aid) {
- g_string_append_printf(meta, "|aid:%s", safe_aid);
- }
- if (safe_rid) {
- g_string_append_printf(meta, "|corrects:%s", safe_rid);
- }
- g_string_append_c(meta, ']');
-
- // Build sender — escape ": " in the resource part to prevent
- // the parser from splitting at the wrong point.
- // JID bare parts (user@domain) never contain ": " but XMPP resources
- // can contain arbitrary UTF-8 including ": ".
- GString* sender = g_string_new(from_jid ? from_jid : "unknown");
- if (from_resource && strlen(from_resource) > 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++) {
- if (*p == '\\') {
- g_string_append(safe_res, "\\\\");
- } else if (*p == ':' && *(p + 1) == ' ') {
- g_string_append(safe_res, "\\: ");
- p++; // skip the space too
- } else {
- g_string_append_c(safe_res, *p);
- }
- }
- g_string_append_printf(sender, "/%s", safe_res->str);
- g_string_free(safe_res, TRUE);
- }
-
- // Escape message body to prevent log injection
- char* safe_msg = _ff_escape_message(message_text);
-
- // Build complete line and write atomically with a single write()
- GString* full_line = g_string_new(NULL);
- g_string_printf(full_line, "%s %s %s: %s\n",
- 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);
- }
-
- g_string_free(full_line, TRUE);
- g_free(safe_msg);
- g_string_free(meta, TRUE);
- g_string_free(sender, TRUE);
-}
-
-// --- Line parser helpers ---
-
-// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash.
-// Returns pointer to 'ch' in 'str', or NULL if not found.
-static const char*
-_ff_find_unescaped_char(const char* str, char ch)
-{
- if (!str)
- return NULL;
- for (const char* p = str; *p; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++; // skip escaped character
- continue;
- }
- if (*p == ch)
- return p;
- }
- return NULL;
-}
-
-// Split metadata content on unescaped '|'. Returns a NULL-terminated array.
-// Caller must g_strfreev() the result.
-static char**
-_ff_split_meta(const char* meta)
-{
- GPtrArray* arr = g_ptr_array_new();
- const char* start = meta;
- for (const char* p = meta;; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++; // skip escaped char
- continue;
- }
- if (*p == '|' || *p == '\0') {
- g_ptr_array_add(arr, g_strndup(start, p - start));
- if (*p == '\0')
- break;
- start = p + 1;
- }
- }
- g_ptr_array_add(arr, NULL);
- return (char**)g_ptr_array_free(arr, FALSE);
-}
-
-// Find first unescaped ": " (colon-space) in a string.
-// Escaped form is "\: " — a backslash before the colon.
-static const char*
-_ff_find_unescaped_colonspace(const char* str)
-{
- if (!str)
- return NULL;
- for (const char* p = str; *p; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++; // skip escaped character
- continue;
- }
- if (*p == ':' && *(p + 1) == ' ') {
- return p;
- }
- }
- return NULL;
-}
-
-// Unescape a sender resource: "\\" -> '\', "\: " -> ": "
-static char*
-_ff_unescape_sender_resource(const char* res)
-{
- if (!res)
- return NULL;
- GString* out = g_string_sized_new(strlen(res));
- for (const char* p = res; *p; p++) {
- if (*p == '\\' && *(p + 1)) {
- p++;
- if (*p == '\\') {
- g_string_append_c(out, '\\');
- } else if (*p == ':' && *(p + 1) == ' ') {
- g_string_append(out, ": ");
- p++; // skip the space
- } else {
- // Unknown escape — preserve literally
- g_string_append_c(out, '\\');
- g_string_append_c(out, *p);
- }
- } else {
- g_string_append_c(out, *p);
- }
- }
- return g_string_free(out, FALSE);
-}
-
-// --- Line parser ---
-
-typedef struct
-{
- char* timestamp_str;
- GDateTime* timestamp;
- char* type;
- char* enc;
- char* stanza_id;
- char* archive_id;
- char* replace_id;
- char* from_jid;
- char* from_resource;
- char* message;
-} flatfile_parsed_line_t;
-
-static void
-_ff_parsed_line_free(flatfile_parsed_line_t* pl)
-{
- if (!pl)
- return;
- g_free(pl->timestamp_str);
- if (pl->timestamp)
- g_date_time_unref(pl->timestamp);
- g_free(pl->type);
- g_free(pl->enc);
- g_free(pl->stanza_id);
- g_free(pl->archive_id);
- g_free(pl->replace_id);
- g_free(pl->from_jid);
- g_free(pl->from_resource);
- g_free(pl->message);
- g_free(pl);
-}
-
-// Parse a single line. Returns NULL on parse failure.
-// Line format: {timestamp} [{metadata}] {sender}: {message}
-static flatfile_parsed_line_t*
-_ff_parse_line(const char* line)
-{
- if (!line || line[0] == '\0' || line[0] == '#') {
- return NULL;
- }
-
- // Strip trailing \r if present (CRLF handling)
- char* work = g_strdup(line);
- gsize len = strlen(work);
- if (len > 0 && work[len - 1] == '\r') {
- work[len - 1] = '\0';
- len--;
- }
- if (len == 0) {
- g_free(work);
- return NULL;
- }
-
- // UTF-8 validation
- const gchar* end;
- if (!g_utf8_validate(work, -1, &end)) {
- log_warning("flatfile: invalid UTF-8 at byte offset %ld", (long)(end - work));
- // Attempt Latin-1 fallback
- gsize br, bw;
- GError* err = NULL;
- char* converted = g_convert(work, -1, "UTF-8", "ISO-8859-1", &br, &bw, &err);
- if (converted) {
- g_free(work);
- work = converted;
- } else {
- if (err)
- g_error_free(err);
- g_free(work);
- return NULL;
- }
- }
-
- flatfile_parsed_line_t* result = g_malloc0(sizeof(flatfile_parsed_line_t));
-
- // Parse timestamp — everything up to first space followed by '['
- // Try: find first '[' — everything before it (trimmed) is the timestamp
- char* bracket_start = strchr(work, '[');
- char* first_space = strchr(work, ' ');
-
- if (bracket_start && first_space && first_space < bracket_start) {
- // Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
- result->timestamp_str = g_strndup(work, first_space - work);
-
- // Parse metadata section [...]
- // Use escape-aware bracket finder to handle escaped ']' in values
- const char* bracket_end = _ff_find_unescaped_char(bracket_start + 1, ']');
- if (bracket_end) {
- char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
-
- // Split by unescaped '|'
- char** parts = _ff_split_meta(meta_content);
- if (parts) {
- int i = 0;
- for (; parts[i]; i++) {
- if (i == 0) {
- result->type = g_strdup(parts[i]);
- } else if (i == 1) {
- result->enc = g_strdup(parts[i]);
- } else if (g_str_has_prefix(parts[i], "id:")) {
- result->stanza_id = _ff_unescape_meta_value(parts[i] + 3);
- } else if (g_str_has_prefix(parts[i], "aid:")) {
- result->archive_id = _ff_unescape_meta_value(parts[i] + 4);
- } else if (g_str_has_prefix(parts[i], "corrects:")) {
- result->replace_id = _ff_unescape_meta_value(parts[i] + 9);
- }
- }
- g_strfreev(parts);
- }
- g_free(meta_content);
-
- // Parse sender: message after '] '
- const char* after_meta = bracket_end + 1;
- if (*after_meta == ' ')
- after_meta++;
-
- // Find first *unescaped* ': ' which separates sender from message.
- // Resources may contain escaped \: sequences.
- const char* colon = _ff_find_unescaped_colonspace(after_meta);
- if (colon) {
- char* raw_sender = g_strndup(after_meta, colon - after_meta);
-
- // Split sender into jid/resource, then unescape resource
- char* slash = strchr(raw_sender, '/');
- if (slash) {
- result->from_jid = g_strndup(raw_sender, slash - raw_sender);
- result->from_resource = _ff_unescape_sender_resource(slash + 1);
- } else {
- result->from_jid = g_strdup(raw_sender);
- }
- g_free(raw_sender);
-
- result->message = _ff_unescape_message(colon + 2);
- } else {
- // No ': ' found, treat entire rest as message with unknown sender
- result->from_jid = g_strdup("unknown");
- result->message = _ff_unescape_message(after_meta);
- }
- } else {
- // No closing bracket — malformed metadata
- _ff_parsed_line_free(result);
- g_free(work);
- return NULL;
- }
- } else if (first_space) {
- // Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
- // Or just {timestamp} {sender}: {msg}
- result->timestamp_str = g_strndup(work, first_space - work);
- result->type = g_strdup("chat");
- result->enc = g_strdup("none");
-
- char* rest = first_space + 1;
- // Skip " - " if present (chatlog.c format)
- if (g_str_has_prefix(rest, "- ")) {
- rest += 2;
- }
-
- char* colon = strstr(rest, ": ");
- if (colon) {
- char* sender = g_strndup(rest, colon - rest);
- char* slash = strchr(sender, '/');
- if (slash) {
- result->from_jid = g_strndup(sender, slash - sender);
- result->from_resource = g_strdup(slash + 1);
- } else {
- result->from_jid = g_strdup(sender);
- }
- g_free(sender);
- result->message = _ff_unescape_message(colon + 2);
- } else {
- result->from_jid = g_strdup("unknown");
- result->message = _ff_unescape_message(rest);
- }
- } else {
- // No space at all — can't parse
- _ff_parsed_line_free(result);
- g_free(work);
- return NULL;
- }
-
- // Parse timestamp
- result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
- if (!result->timestamp) {
- log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
- _ff_parsed_line_free(result);
- g_free(work);
- return NULL;
- }
-
- // Default type/enc if missing
- if (!result->type)
- result->type = g_strdup("chat");
- if (!result->enc)
- result->enc = g_strdup("none");
-
- g_free(work);
- return result;
-}
-
-// Convert parsed line to ProfMessage
-static ProfMessage*
-_ff_parsed_to_profmessage(flatfile_parsed_line_t* pl)
-{
- ProfMessage* msg = message_init();
- msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
- msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
- msg->plain = g_strdup(pl->message ? pl->message : "");
- msg->timestamp = g_date_time_ref(pl->timestamp);
- msg->type = _ff_get_message_type_type(pl->type);
- msg->enc = _ff_get_message_enc_type(pl->enc);
- return msg;
-}
-
-// --- Core write logic ---
+// =========================================================================
+// Core write logic
+// =========================================================================
static void
_ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
@@ -806,7 +89,7 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
contact = from_barejid;
}
- auto_gchar gchar* log_path = _ff_get_log_path(contact, dt);
+ auto_gchar gchar* log_path = ff_get_log_path(contact, dt);
g_date_time_unref(dt);
if (!log_path) {
@@ -817,7 +100,7 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
// Ensure directory exists
auto_gchar gchar* dir = g_path_get_dirname(log_path);
- if (!_ff_ensure_dir(dir)) {
+ if (!ff_ensure_dir(dir)) {
g_free(effective_msg);
return;
}
@@ -854,16 +137,18 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
fprintf(fp, "%s", FLATFILE_HEADER);
}
- _ff_write_line(fp, date_fmt, type, _ff_get_message_enc_str(enc),
- stanza_id, archive_id, replace_id,
- from_barejid, from_resource, effective_msg);
+ ff_write_line(fp, date_fmt, type, ff_get_message_enc_str(enc),
+ stanza_id, archive_id, replace_id,
+ from_barejid, from_resource, effective_msg);
fflush(fp);
fclose(fp);
g_free(effective_msg);
}
-// --- Backend implementation ---
+// =========================================================================
+// Backend callbacks: init / close
+// =========================================================================
static gboolean
_flatfile_init(ProfAccount* account)
@@ -878,10 +163,10 @@ _flatfile_init(ProfAccount* account)
// Ensure base directory exists
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
- auto_gchar gchar* my_dir = _ff_jid_to_dir(g_flatfile_account_jid);
+ auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
- if (!_ff_ensure_dir(base_dir)) {
+ if (!ff_ensure_dir(base_dir)) {
return FALSE;
}
@@ -897,6 +182,10 @@ _flatfile_close(void)
log_debug("flatfile: closed");
}
+// =========================================================================
+// Backend callbacks: add message
+// =========================================================================
+
static void
_flatfile_add_incoming(ProfMessage* message)
{
@@ -904,7 +193,7 @@ _flatfile_add_incoming(ProfMessage* message)
return;
const Jid* to_jid = message->to_jid ? message->to_jid : connection_get_jid();
- const char* type = _ff_get_message_type_str(message->type);
+ const char* type = ff_get_message_type_str(message->type);
_ff_add_message(type, message->id, message->stanzaid, message->replace_id,
message->from_jid->barejid, message->from_jid->resourcepart,
@@ -942,13 +231,15 @@ _flatfile_add_outgoing_muc_pm(const char* const id, const char* const barejid,
barejid, message, NULL, enc);
}
-// --- Read logic ---
+// =========================================================================
+// Read logic
+// =========================================================================
// List all log files for a contact, sorted by filename (date order)
static GSList*
_ff_list_log_files(const char* contact_barejid)
{
- auto_gchar gchar* contact_dir = _ff_get_contact_dir(contact_barejid);
+ auto_gchar gchar* contact_dir = ff_get_contact_dir(contact_barejid);
if (!contact_dir || !g_file_test(contact_dir, G_FILE_TEST_IS_DIR)) {
return NULL;
}
@@ -993,14 +284,14 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
char* buf = NULL;
gboolean truncated = FALSE;
- while ((buf = _ff_readline(fp, &truncated)) != NULL) {
+ while ((buf = ff_readline(fp, &truncated)) != NULL) {
if (truncated) {
log_warning("flatfile: truncated line at EOF in %s, skipping", filepath);
free(buf);
break;
}
- flatfile_parsed_line_t* pl = _ff_parse_line(buf);
+ ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (!pl)
continue;
@@ -1011,7 +302,7 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
if (start_dt) {
if (g_date_time_compare(pl->timestamp, start_dt) <= 0) {
g_date_time_unref(start_dt);
- _ff_parsed_line_free(pl);
+ ff_parsed_line_free(pl);
continue;
}
g_date_time_unref(start_dt);
@@ -1022,7 +313,7 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
if (end_dt) {
if (g_date_time_compare(pl->timestamp, end_dt) >= 0) {
g_date_time_unref(end_dt);
- _ff_parsed_line_free(pl);
+ ff_parsed_line_free(pl);
continue;
}
g_date_time_unref(end_dt);
@@ -1040,7 +331,7 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
}
if (!matches) {
- _ff_parsed_line_free(pl);
+ ff_parsed_line_free(pl);
continue;
}
@@ -1058,7 +349,7 @@ _ff_apply_lmc(GSList* parsed_lines, GSList** result)
// Build a hash: stanza_id -> parsed_line for quick lookup
GHashTable* id_map = g_hash_table_new(g_str_hash, g_str_equal);
for (GSList* l = parsed_lines; l; l = l->next) {
- flatfile_parsed_line_t* pl = l->data;
+ ff_parsed_line_t* pl = l->data;
if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
g_hash_table_insert(id_map, pl->stanza_id, pl);
}
@@ -1070,17 +361,17 @@ _ff_apply_lmc(GSList* parsed_lines, GSList** result)
GHashTable* correction_map = g_hash_table_new(g_direct_hash, g_direct_equal);
for (GSList* l = parsed_lines; l; l = l->next) {
- flatfile_parsed_line_t* pl = l->data;
+ ff_parsed_line_t* pl = l->data;
if (pl->replace_id && strlen(pl->replace_id) > 0) {
- flatfile_parsed_line_t* original = g_hash_table_lookup(id_map, pl->replace_id);
+ ff_parsed_line_t* original = g_hash_table_lookup(id_map, pl->replace_id);
if (original) {
// Follow chain to the root original, with cycle/depth guard
- flatfile_parsed_line_t* root = original;
+ ff_parsed_line_t* root = original;
GHashTable* visited = g_hash_table_new(g_direct_hash, g_direct_equal);
g_hash_table_insert(visited, root, root);
int depth = 0;
while (root->replace_id && strlen(root->replace_id) > 0 && depth < FF_MAX_LMC_DEPTH) {
- flatfile_parsed_line_t* parent = g_hash_table_lookup(id_map, root->replace_id);
+ ff_parsed_line_t* parent = g_hash_table_lookup(id_map, root->replace_id);
if (parent && !g_hash_table_contains(visited, parent)) {
g_hash_table_insert(visited, parent, parent);
root = parent;
@@ -1101,20 +392,20 @@ _ff_apply_lmc(GSList* parsed_lines, GSList** result)
// Build result: for each non-correction line, output it (with corrected text if applicable)
for (GSList* l = parsed_lines; l; l = l->next) {
- flatfile_parsed_line_t* pl = l->data;
+ ff_parsed_line_t* pl = l->data;
if (g_hash_table_lookup(corrections, pl)) {
continue; // skip correction-only lines
}
- flatfile_parsed_line_t* latest_correction = g_hash_table_lookup(correction_map, pl);
+ ff_parsed_line_t* latest_correction = g_hash_table_lookup(correction_map, pl);
ProfMessage* msg;
if (latest_correction) {
// Use corrected text with original's metadata
- msg = _ff_parsed_to_profmessage(pl);
+ msg = ff_parsed_to_profmessage(pl);
g_free(msg->plain);
msg->plain = g_strdup(latest_correction->message ? latest_correction->message : "");
} else {
- msg = _ff_parsed_to_profmessage(pl);
+ msg = ff_parsed_to_profmessage(pl);
}
*result = g_slist_append(*result, msg);
}
@@ -1124,6 +415,10 @@ _ff_apply_lmc(GSList* parsed_lines, GSList** result)
g_hash_table_destroy(correction_map);
}
+// =========================================================================
+// Backend callbacks: query history
+// =========================================================================
+
static db_history_result_t
_flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* start_time,
const gchar* end_time, gboolean from_start, gboolean flip,
@@ -1175,7 +470,7 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
_ff_apply_lmc(all_parsed, &pre_result);
// Free parsed lines
- g_slist_free_full(all_parsed, (GDestroyNotify)_ff_parsed_line_free);
+ g_slist_free_full(all_parsed, (GDestroyNotify)ff_parsed_line_free);
if (!pre_result) {
return DB_RESPONSE_EMPTY;
@@ -1254,24 +549,24 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
}
char* buf = NULL;
- flatfile_parsed_line_t* found = NULL;
+ ff_parsed_line_t* found = NULL;
if (!is_last) {
// Find first valid line
- while ((buf = _ff_readline(fp, NULL)) != NULL) {
- found = _ff_parse_line(buf);
+ while ((buf = ff_readline(fp, NULL)) != NULL) {
+ found = ff_parse_line(buf);
free(buf);
if (found)
break;
}
} else {
// Find last valid line — read all and keep last
- while ((buf = _ff_readline(fp, NULL)) != NULL) {
- flatfile_parsed_line_t* pl = _ff_parse_line(buf);
+ while ((buf = ff_readline(fp, NULL)) != NULL) {
+ ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
if (found)
- _ff_parsed_line_free(found);
+ ff_parsed_line_free(found);
found = pl;
}
}
@@ -1281,7 +576,7 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
if (found) {
msg->stanzaid = found->archive_id ? g_strdup(found->archive_id) : NULL;
msg->timestamp = g_date_time_ref(found->timestamp);
- _ff_parsed_line_free(found);
+ ff_parsed_line_free(found);
} else if (is_last) {
msg->timestamp = g_date_time_new_now_utc();
}
@@ -1290,353 +585,9 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
return msg;
}
-// --- Integrity verification ---
-
-static GSList*
-_flatfile_verify_integrity(const gchar* const contact_barejid)
-{
- GSList* issues = NULL;
-
- if (!g_flatfile_account_jid) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_ERROR;
- issue->file = g_strdup("N/A");
- issue->line = 0;
- issue->message = g_strdup("Flat-file backend not initialized");
- issues = g_slist_append(issues, issue);
- return issues;
- }
-
- // If contact specified, verify just that contact; otherwise discover all contacts
- GSList* contact_dirs = NULL;
-
- if (contact_barejid) {
- auto_gchar gchar* cdir = _ff_get_contact_dir(contact_barejid);
- if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
- contact_dirs = g_slist_append(contact_dirs, g_strdup(cdir));
- } else {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_INFO;
- issue->file = g_strdup(contact_barejid);
- issue->line = 0;
- issue->message = g_strdup("No log files found for this contact");
- issues = g_slist_append(issues, issue);
- return issues;
- }
- } else {
- // Discover all contact directories
- auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
- auto_gchar gchar* my_dir = _ff_jid_to_dir(g_flatfile_account_jid);
- auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
-
- GDir* dir = g_dir_open(base_dir, 0, NULL);
- if (dir) {
- const gchar* dname;
- while ((dname = g_dir_read_name(dir)) != NULL) {
- char* full = g_strdup_printf("%s/%s", base_dir, dname);
- if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
- contact_dirs = g_slist_append(contact_dirs, full);
- } else {
- g_free(full);
- }
- }
- g_dir_close(dir);
- }
- }
-
- // Verify each contact directory
- for (GSList* cd = contact_dirs; cd; cd = cd->next) {
- const char* cdir_path = cd->data;
-
- GDir* dir = g_dir_open(cdir_path, 0, NULL);
- if (!dir)
- continue;
-
- GSList* log_files = NULL;
- const gchar* fname;
- while ((fname = g_dir_read_name(dir)) != NULL) {
- if (g_str_has_suffix(fname, ".log")) {
- log_files = g_slist_insert_sorted(log_files,
- g_strdup_printf("%s/%s", cdir_path, fname),
- (GCompareFunc)g_strcmp0);
- }
- }
- g_dir_close(dir);
-
- GDateTime* prev_file_last_ts = NULL;
- GHashTable* seen_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
- GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
-
- for (GSList* lf = log_files; lf; lf = lf->next) {
- const char* filepath = lf->data;
- const char* basename = strrchr(filepath, '/');
- basename = basename ? basename + 1 : filepath;
-
- // Check file permissions (4i)
- struct stat st;
- if (g_stat(filepath, &st) == 0) {
- if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = 0;
- issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777);
- issues = g_slist_append(issues, issue);
- }
- }
-
- FILE* fp = fopen(filepath, "r");
- if (!fp)
- continue;
-
- // BOM check (4l)
- int c1 = fgetc(fp);
- int c2 = fgetc(fp);
- int c3 = fgetc(fp);
- if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_INFO;
- issue->file = g_strdup(basename);
- issue->line = 0;
- issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
- issues = g_slist_append(issues, issue);
- } else {
- fseek(fp, 0, SEEK_SET);
- }
-
- char* buf = NULL;
- int lineno = 0;
- GDateTime* prev_ts = NULL;
- GDateTime* first_ts = NULL;
- GDateTime* last_ts = NULL;
- gboolean has_crlf = FALSE;
- gboolean is_empty = TRUE;
-
- while ((buf = _ff_readline(fp, NULL)) != NULL) {
- lineno++;
- gsize len = strlen(buf);
-
- // CRLF check (4m)
- if (len > 0 && buf[len - 1] == '\r') {
- has_crlf = TRUE;
- buf[--len] = '\0';
- }
-
- // Skip empty lines and comments
- if (len == 0 || buf[0] == '#') {
- free(buf);
- continue;
- }
- is_empty = FALSE;
-
- // UTF-8 validation (4k/4n)
- const gchar* end;
- if (!g_utf8_validate(buf, -1, &end)) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_ERROR;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf));
- issues = g_slist_append(issues, issue);
- free(buf);
- continue;
- }
-
- // Control character check (4o)
- for (gsize i = 0; i < len; i++) {
- unsigned char ch = (unsigned char)buf[i];
- if (ch < 0x20 && ch != '\t') {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
- issues = g_slist_append(issues, issue);
- break;
- }
- }
-
- // Parse line (4a, 4b)
- flatfile_parsed_line_t* pl = _ff_parse_line(buf);
- if (!pl) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_ERROR;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup("Unparsable line");
- issues = g_slist_append(issues, issue);
- free(buf);
- continue;
- }
-
- free(buf); // done with raw line
-
- if (!first_ts)
- first_ts = g_date_time_ref(pl->timestamp);
- if (last_ts)
- g_date_time_unref(last_ts);
- last_ts = g_date_time_ref(pl->timestamp);
-
- // Timestamp order within file (4c)
- if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
- auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
- auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
- issues = g_slist_append(issues, issue);
- }
- if (prev_ts)
- g_date_time_unref(prev_ts);
- prev_ts = g_date_time_ref(pl->timestamp);
-
- // Duplicate stanza-id / archive-id (4f)
- if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
- if (g_hash_table_contains(seen_ids, pl->stanza_id)) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id);
- issues = g_slist_append(issues, issue);
- } else {
- g_hash_table_insert(seen_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
- }
- // Also track for LMC checking
- g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
- }
- if (pl->archive_id && strlen(pl->archive_id) > 0) {
- if (g_hash_table_contains(seen_ids, pl->archive_id)) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = lineno;
- issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
- issues = g_slist_append(issues, issue);
- } else {
- g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
- }
- }
-
- // Broken LMC reference (4g) — collect for later batch check
- // (We'll check after reading all files for this contact)
-
- _ff_parsed_line_free(pl);
- }
-
- fclose(fp);
-
- // CRLF warning for file
- if (has_crlf) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = 0;
- issue->message = g_strdup("File uses Windows line endings (CRLF) — consider converting to LF");
- issues = g_slist_append(issues, issue);
- }
-
- // Empty file (4j)
- if (is_empty) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_INFO;
- issue->file = g_strdup(basename);
- issue->line = 0;
- issue->message = g_strdup("File is empty (no message lines)");
- issues = g_slist_append(issues, issue);
- }
-
- // Cross-file timestamp ordering (4d)
- if (first_ts && prev_file_last_ts) {
- if (g_date_time_compare(first_ts, prev_file_last_ts) < 0) {
- auto_gchar gchar* ts_first = g_date_time_format_iso8601(first_ts);
- auto_gchar gchar* ts_prev_last = g_date_time_format_iso8601(prev_file_last_ts);
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_WARNING;
- issue->file = g_strdup(basename);
- issue->line = 0;
- issue->message = g_strdup_printf("First timestamp (%s) is before previous file's last timestamp (%s)",
- ts_first, ts_prev_last);
- issues = g_slist_append(issues, issue);
- }
- }
-
- if (prev_file_last_ts)
- g_date_time_unref(prev_file_last_ts);
- prev_file_last_ts = last_ts ? g_date_time_ref(last_ts) : NULL;
-
- if (prev_ts)
- g_date_time_unref(prev_ts);
- if (first_ts)
- g_date_time_unref(first_ts);
- if (last_ts)
- g_date_time_unref(last_ts);
- }
-
- // Second pass: check LMC references across all files (4g)
- for (GSList* lf = log_files; lf; lf = lf->next) {
- const char* filepath = lf->data;
- const char* basename_lmc = strrchr(filepath, '/');
- basename_lmc = basename_lmc ? basename_lmc + 1 : filepath;
-
- FILE* fp = fopen(filepath, "r");
- if (!fp)
- continue;
-
- // Skip BOM
- int b1 = fgetc(fp);
- int b2 = fgetc(fp);
- int b3 = fgetc(fp);
- if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF)) {
- fseek(fp, 0, SEEK_SET);
- }
-
- char* buf = NULL;
- int lineno = 0;
- while ((buf = _ff_readline(fp, NULL)) != NULL) {
- lineno++;
- gsize len = strlen(buf);
- if (len > 0 && buf[len - 1] == '\r')
- buf[--len] = '\0';
- if (len == 0 || buf[0] == '#') {
- free(buf);
- continue;
- }
-
- flatfile_parsed_line_t* pl = _ff_parse_line(buf);
- free(buf);
- if (!pl)
- continue;
-
- if (pl->replace_id && strlen(pl->replace_id) > 0) {
- if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
- integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
- issue->level = INTEGRITY_ERROR;
- issue->file = g_strdup(basename_lmc);
- issue->line = lineno;
- issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
- issues = g_slist_append(issues, issue);
- }
- }
- _ff_parsed_line_free(pl);
- }
- fclose(fp);
- }
-
- if (prev_file_last_ts)
- g_date_time_unref(prev_file_last_ts);
- g_hash_table_destroy(seen_ids);
- g_hash_table_destroy(all_stanza_ids);
- g_slist_free_full(log_files, g_free);
- }
-
- g_slist_free_full(contact_dirs, g_free);
- return issues;
-}
-
-// --- Backend vtable ---
+// =========================================================================
+// Backend vtable
+// =========================================================================
static db_backend_t flatfile_backend = {
.name = "flatfile",
@@ -1648,7 +599,7 @@ static db_backend_t flatfile_backend = {
.add_outgoing_muc_pm = _flatfile_add_outgoing_muc_pm,
.get_previous_chat = _flatfile_get_previous_chat,
.get_limits_info = _flatfile_get_limits_info,
- .verify_integrity = _flatfile_verify_integrity,
+ .verify_integrity = ff_verify_integrity,
};
db_backend_t*
diff --git a/src/database_flatfile.h b/src/database_flatfile.h
new file mode 100644
index 00000000..febdd311
--- /dev/null
+++ b/src/database_flatfile.h
@@ -0,0 +1,110 @@
+/*
+ * database_flatfile.h
+ * vim: expandtab:ts=4:sts=4:sw=4
+ *
+ * Copyright (C) 2026 Profanity Contributors
+ *
+ * This file is part of Profanity.
+ *
+ * Profanity is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Profanity is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Profanity. If not, see .
+ *
+ * Internal header shared between database_flatfile*.c modules.
+ * Not part of the public API — do not include from outside the flatfile backend.
+ */
+
+#ifndef DATABASE_FLATFILE_H
+#define DATABASE_FLATFILE_H
+
+#include
+#include
+
+#include "database.h"
+#include "xmpp/xmpp.h"
+#include "xmpp/message.h"
+
+// --- 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 */
+
+// --- Shared global ---
+
+// Account JID stored during init for path construction.
+// Defined in database_flatfile.c, used by all flatfile modules.
+extern char* g_flatfile_account_jid;
+
+// --- Parsed line structure ---
+
+typedef struct
+{
+ char* timestamp_str;
+ GDateTime* timestamp;
+ char* type;
+ char* enc;
+ char* stanza_id;
+ char* archive_id;
+ char* replace_id;
+ char* from_jid;
+ char* from_resource;
+ char* message;
+} ff_parsed_line_t;
+
+// --- Type conversion helpers ---
+
+const char* ff_get_message_type_str(prof_msg_type_t type);
+prof_msg_type_t ff_get_message_type_type(const char* const type);
+const char* ff_get_message_enc_str(prof_enc_t enc);
+prof_enc_t ff_get_message_enc_type(const char* const encstr);
+
+// --- Path helpers ---
+
+char* ff_jid_to_dir(const char* jid);
+char* ff_get_contact_dir(const char* contact_barejid);
+char* ff_get_log_path(const char* contact_barejid, GDateTime* dt);
+gboolean ff_ensure_dir(const char* path);
+
+// --- Escape / unescape ---
+
+char* ff_escape_message(const char* text);
+char* ff_unescape_message(const char* text);
+char* ff_escape_meta_value(const char* val);
+char* ff_unescape_meta_value(const char* val);
+
+// --- I/O ---
+
+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,
+ const char* from_jid, const char* from_resource, const char* message_text);
+
+// --- Parser helpers ---
+
+const char* ff_find_unescaped_char(const char* str, char ch);
+char** ff_split_meta(const char* meta);
+const char* ff_find_unescaped_colonspace(const char* str);
+char* ff_unescape_sender_resource(const char* res);
+
+// --- Parser ---
+
+void ff_parsed_line_free(ff_parsed_line_t* pl);
+ff_parsed_line_t* ff_parse_line(const char* line);
+ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
+
+// --- Integrity verification (database_flatfile_verify.c) ---
+
+GSList* ff_verify_integrity(const gchar* const contact_barejid);
+
+#endif
diff --git a/src/database_flatfile_parser.c b/src/database_flatfile_parser.c
new file mode 100644
index 00000000..a97927f2
--- /dev/null
+++ b/src/database_flatfile_parser.c
@@ -0,0 +1,752 @@
+/*
+ * database_flatfile_parser.c
+ * vim: expandtab:ts=4:sts=4:sw=4
+ *
+ * Copyright (C) 2026 Profanity Contributors
+ *
+ * This file is part of Profanity.
+ *
+ * Profanity is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Profanity is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Profanity. If not, see .
+ *
+ * Flat-file backend: type helpers, path helpers, escape/unescape,
+ * line I/O, and the tolerant log-line parser.
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "log.h"
+#include "common.h"
+#include "config/files.h"
+#include "database_flatfile.h"
+
+// =========================================================================
+// Type conversion helpers
+// =========================================================================
+
+const char*
+ff_get_message_type_str(prof_msg_type_t type)
+{
+ switch (type) {
+ case PROF_MSG_TYPE_CHAT:
+ return "chat";
+ case PROF_MSG_TYPE_MUC:
+ return "muc";
+ case PROF_MSG_TYPE_MUCPM:
+ return "mucpm";
+ case PROF_MSG_TYPE_UNINITIALIZED:
+ return "chat";
+ }
+ return "chat";
+}
+
+prof_msg_type_t
+ff_get_message_type_type(const char* const type)
+{
+ if (g_strcmp0(type, "chat") == 0) {
+ return PROF_MSG_TYPE_CHAT;
+ } else if (g_strcmp0(type, "muc") == 0) {
+ return PROF_MSG_TYPE_MUC;
+ } else if (g_strcmp0(type, "mucpm") == 0) {
+ return PROF_MSG_TYPE_MUCPM;
+ }
+ return PROF_MSG_TYPE_CHAT;
+}
+
+const char*
+ff_get_message_enc_str(prof_enc_t enc)
+{
+ switch (enc) {
+ case PROF_MSG_ENC_OX:
+ return "ox";
+ case PROF_MSG_ENC_PGP:
+ return "pgp";
+ case PROF_MSG_ENC_OTR:
+ return "otr";
+ case PROF_MSG_ENC_OMEMO:
+ return "omemo";
+ case PROF_MSG_ENC_NONE:
+ return "none";
+ }
+ return "none";
+}
+
+prof_enc_t
+ff_get_message_enc_type(const char* const encstr)
+{
+ if (g_strcmp0(encstr, "ox") == 0) {
+ return PROF_MSG_ENC_OX;
+ } else if (g_strcmp0(encstr, "pgp") == 0) {
+ return PROF_MSG_ENC_PGP;
+ } else if (g_strcmp0(encstr, "otr") == 0) {
+ return PROF_MSG_ENC_OTR;
+ } else if (g_strcmp0(encstr, "omemo") == 0) {
+ return PROF_MSG_ENC_OMEMO;
+ }
+ return PROF_MSG_ENC_NONE;
+}
+
+// =========================================================================
+// Path helpers
+// =========================================================================
+
+// Sanitise a JID for use as a directory name.
+// 1. Replace '@' with '_at_'
+// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
+// This prevents a malicious federated JID like "../../../tmp/pwned" from
+// escaping the log directory tree.
+char*
+ff_jid_to_dir(const char* jid)
+{
+ if (!jid || jid[0] == '\0')
+ return NULL;
+
+ // Replace '@' first
+ char* step1 = str_replace(jid, "@", "_at_");
+ if (!step1)
+ return NULL;
+
+ // Replace '/' and '\\' with '_' to prevent path traversal
+ GString* out = g_string_sized_new(strlen(step1));
+ for (const char* p = step1; *p; p++) {
+ if (*p == '/' || *p == '\\') {
+ g_string_append_c(out, '_');
+ } else {
+ g_string_append_c(out, *p);
+ }
+ }
+ free(step1);
+
+ // Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
+ char* result = g_string_free(out, FALSE);
+ char* dotdot;
+ while ((dotdot = strstr(result, "..")) != NULL) {
+ dotdot[0] = '_';
+ dotdot[1] = '_';
+ }
+
+ // Reject empty result
+ if (result[0] == '\0') {
+ g_free(result);
+ return NULL;
+ }
+ return result;
+}
+
+// Get the base directory for a contact's logs:
+// ~/.local/share/profanity/flatlog/{my_jid_dir}/{contact_jid_dir}/
+char*
+ff_get_contact_dir(const char* contact_barejid)
+{
+ if (!g_flatfile_account_jid || !contact_barejid)
+ return NULL;
+
+ auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
+ auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
+ auto_gchar gchar* contact_dir = ff_jid_to_dir(contact_barejid);
+
+ char* result = g_strdup_printf("%s/%s/%s", data_path, my_dir, contact_dir);
+ return result;
+}
+
+// Get the log file path for a contact on a specific date:
+// {contact_dir}/{YYYY_MM_DD}.log
+char*
+ff_get_log_path(const char* contact_barejid, GDateTime* dt)
+{
+ auto_gchar gchar* contact_dir = ff_get_contact_dir(contact_barejid);
+ if (!contact_dir)
+ return NULL;
+
+ auto_gchar gchar* date_str = g_date_time_format(dt, "%Y_%m_%d");
+ char* result = g_strdup_printf("%s/%s.log", contact_dir, date_str);
+ return result;
+}
+
+// Ensure the directory exists, create if needed.
+// Refuses to follow symlinks at the final component.
+gboolean
+ff_ensure_dir(const char* path)
+{
+ if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
+ // Verify it's not a symlink
+ struct stat st;
+ if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
+ log_error("flatfile: directory path is a symlink, refusing: %s", path);
+ return FALSE;
+ }
+ return TRUE;
+ }
+ if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
+ log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+// =========================================================================
+// Escape / unescape helpers
+// =========================================================================
+//
+// Message text and metadata values from remote peers can contain arbitrary
+// characters including newlines, pipes and brackets. Without escaping, a
+// crafted message could inject fake log lines (log injection / format
+// injection). We escape on write and unescape on read.
+
+// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal
+char*
+ff_escape_message(const char* text)
+{
+ if (!text)
+ return g_strdup("");
+ GString* out = g_string_sized_new(strlen(text));
+ for (const char* p = text; *p; p++) {
+ switch (*p) {
+ case '\\':
+ g_string_append(out, "\\\\");
+ break;
+ case '\n':
+ g_string_append(out, "\\n");
+ break;
+ case '\r':
+ g_string_append(out, "\\r");
+ break;
+ default:
+ g_string_append_c(out, *p);
+ break;
+ }
+ }
+ return g_string_free(out, FALSE);
+}
+
+// Unescape message body: \\ -> \, \n -> newline, \r -> CR
+char*
+ff_unescape_message(const char* text)
+{
+ if (!text)
+ return g_strdup("");
+ GString* out = g_string_sized_new(strlen(text));
+ for (const char* p = text; *p; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++;
+ switch (*p) {
+ case '\\':
+ g_string_append_c(out, '\\');
+ break;
+ case 'n':
+ g_string_append_c(out, '\n');
+ break;
+ case 'r':
+ g_string_append_c(out, '\r');
+ break;
+ default:
+ g_string_append_c(out, '\\');
+ g_string_append_c(out, *p);
+ break;
+ }
+ } else {
+ g_string_append_c(out, *p);
+ }
+ }
+ return g_string_free(out, FALSE);
+}
+
+// Escape metadata value (stanza_id, archive_id, replace_id):
+// these come from remote servers and may contain |, ], \, newlines.
+char*
+ff_escape_meta_value(const char* val)
+{
+ if (!val || strlen(val) == 0)
+ return NULL;
+ GString* out = g_string_sized_new(strlen(val));
+ for (const char* p = val; *p; p++) {
+ switch (*p) {
+ case '|':
+ g_string_append(out, "\\|");
+ break;
+ case ']':
+ g_string_append(out, "\\]");
+ break;
+ case '\\':
+ g_string_append(out, "\\\\");
+ break;
+ case '\n':
+ g_string_append(out, "\\n");
+ break;
+ case '\r':
+ g_string_append(out, "\\r");
+ break;
+ default:
+ g_string_append_c(out, *p);
+ break;
+ }
+ }
+ return g_string_free(out, FALSE);
+}
+
+// Unescape metadata value
+char*
+ff_unescape_meta_value(const char* val)
+{
+ if (!val)
+ return NULL;
+ GString* out = g_string_sized_new(strlen(val));
+ for (const char* p = val; *p; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++;
+ switch (*p) {
+ case '|':
+ g_string_append_c(out, '|');
+ break;
+ case ']':
+ g_string_append_c(out, ']');
+ break;
+ case '\\':
+ g_string_append_c(out, '\\');
+ break;
+ case 'n':
+ g_string_append_c(out, '\n');
+ break;
+ case 'r':
+ g_string_append_c(out, '\r');
+ break;
+ default:
+ g_string_append_c(out, '\\');
+ g_string_append_c(out, *p);
+ break;
+ }
+ } else {
+ g_string_append_c(out, *p);
+ }
+ }
+ return g_string_free(out, FALSE);
+}
+
+// =========================================================================
+// Readline helper
+// =========================================================================
+//
+// POSIX getline() for dynamic-length lines. Returns line without trailing
+// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
+// newline at EOF (partial write detection).
+char*
+ff_readline(FILE* fp, gboolean* truncated)
+{
+ char* line = NULL;
+ size_t cap = 0;
+ ssize_t nread = getline(&line, &cap, fp);
+ if (nread == -1) {
+ free(line);
+ return NULL;
+ }
+ // Guard against pathological lines that could exhaust memory.
+ // getline() already allocated, so we free and skip if too long.
+ if (nread > FF_MAX_LINE_LEN) {
+ log_error("flatfile: line too long (%zd bytes), skipping", nread);
+ // Check newline termination before freeing
+ gboolean had_newline = (nread > 0 && line[nread - 1] == '\n');
+ free(line);
+ // Skip to next newline if the overlength line wasn't newline-terminated
+ if (!had_newline) {
+ int ch;
+ while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
+ }
+ if (truncated)
+ *truncated = FALSE;
+ // Return empty string so caller's loop continues (parse will reject it)
+ return g_strdup("");
+ }
+ if (truncated)
+ *truncated = FALSE;
+ if (nread > 0 && line[nread - 1] == '\n') {
+ line[--nread] = '\0';
+ } else if (feof(fp)) {
+ // Line without trailing newline at EOF — likely a partial write
+ if (truncated)
+ *truncated = TRUE;
+ }
+ return line;
+}
+
+// =========================================================================
+// Line writer
+// =========================================================================
+//
+// Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message}
+//
+// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r
+// Metadata values are escaped: |, ], \\, newline, CR
+// Lines starting with '#' are comments.
+// Empty lines are skipped.
+
+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,
+ const char* from_jid, const char* from_resource, const char* message_text)
+{
+ // Escape metadata values from remote peers
+ auto_gchar gchar* safe_sid = ff_escape_meta_value(stanza_id);
+ auto_gchar gchar* safe_aid = ff_escape_meta_value(archive_id);
+ auto_gchar gchar* safe_rid = ff_escape_meta_value(replace_id);
+
+ // Build metadata section: [type|enc|id:...|aid:...|corrects:...]
+ GString* meta = g_string_new("[");
+ g_string_append(meta, type ? type : "chat");
+ g_string_append_c(meta, '|');
+ g_string_append(meta, enc ? enc : "none");
+ if (safe_sid) {
+ g_string_append_printf(meta, "|id:%s", safe_sid);
+ }
+ if (safe_aid) {
+ g_string_append_printf(meta, "|aid:%s", safe_aid);
+ }
+ if (safe_rid) {
+ g_string_append_printf(meta, "|corrects:%s", safe_rid);
+ }
+ g_string_append_c(meta, ']');
+
+ // 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) {
+ // 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++) {
+ if (*p == '\\') {
+ g_string_append(safe_res, "\\\\");
+ } else if (*p == ':' && *(p + 1) == ' ') {
+ g_string_append(safe_res, "\\: ");
+ p++; // skip the space too
+ } else {
+ g_string_append_c(safe_res, *p);
+ }
+ }
+ g_string_append_printf(sender, "/%s", safe_res->str);
+ g_string_free(safe_res, TRUE);
+ }
+
+ // Escape message body to prevent log injection
+ char* safe_msg = ff_escape_message(message_text);
+
+ // Build complete line and write with a single fwrite()
+ GString* full_line = g_string_new(NULL);
+ g_string_printf(full_line, "%s %s %s: %s\n",
+ 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);
+ }
+
+ g_string_free(full_line, TRUE);
+ g_free(safe_msg);
+ g_string_free(meta, TRUE);
+ g_string_free(sender, TRUE);
+}
+
+// =========================================================================
+// Parser helpers
+// =========================================================================
+
+// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash.
+const char*
+ff_find_unescaped_char(const char* str, char ch)
+{
+ if (!str)
+ return NULL;
+ for (const char* p = str; *p; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++; // skip escaped character
+ continue;
+ }
+ if (*p == ch)
+ return p;
+ }
+ return NULL;
+}
+
+// Split metadata content on unescaped '|'. Returns a NULL-terminated array.
+// Caller must g_strfreev() the result.
+char**
+ff_split_meta(const char* meta)
+{
+ GPtrArray* arr = g_ptr_array_new();
+ const char* start = meta;
+ for (const char* p = meta;; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++; // skip escaped char
+ continue;
+ }
+ if (*p == '|' || *p == '\0') {
+ g_ptr_array_add(arr, g_strndup(start, p - start));
+ if (*p == '\0')
+ break;
+ start = p + 1;
+ }
+ }
+ g_ptr_array_add(arr, NULL);
+ return (char**)g_ptr_array_free(arr, FALSE);
+}
+
+// Find first unescaped ": " (colon-space) in a string.
+const char*
+ff_find_unescaped_colonspace(const char* str)
+{
+ if (!str)
+ return NULL;
+ for (const char* p = str; *p; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++; // skip escaped character
+ continue;
+ }
+ if (*p == ':' && *(p + 1) == ' ') {
+ return p;
+ }
+ }
+ return NULL;
+}
+
+// Unescape a sender resource: "\\" -> '\', "\: " -> ": "
+char*
+ff_unescape_sender_resource(const char* res)
+{
+ if (!res)
+ return NULL;
+ GString* out = g_string_sized_new(strlen(res));
+ for (const char* p = res; *p; p++) {
+ if (*p == '\\' && *(p + 1)) {
+ p++;
+ if (*p == '\\') {
+ g_string_append_c(out, '\\');
+ } else if (*p == ':' && *(p + 1) == ' ') {
+ g_string_append(out, ": ");
+ p++; // skip the space
+ } else {
+ // Unknown escape — preserve literally
+ g_string_append_c(out, '\\');
+ g_string_append_c(out, *p);
+ }
+ } else {
+ g_string_append_c(out, *p);
+ }
+ }
+ return g_string_free(out, FALSE);
+}
+
+// =========================================================================
+// Line parser
+// =========================================================================
+
+void
+ff_parsed_line_free(ff_parsed_line_t* pl)
+{
+ if (!pl)
+ return;
+ g_free(pl->timestamp_str);
+ if (pl->timestamp)
+ g_date_time_unref(pl->timestamp);
+ g_free(pl->type);
+ g_free(pl->enc);
+ g_free(pl->stanza_id);
+ g_free(pl->archive_id);
+ g_free(pl->replace_id);
+ g_free(pl->from_jid);
+ g_free(pl->from_resource);
+ g_free(pl->message);
+ g_free(pl);
+}
+
+// Parse a single line. Returns NULL on parse failure.
+// Line format: {timestamp} [{metadata}] {sender}: {message}
+ff_parsed_line_t*
+ff_parse_line(const char* line)
+{
+ if (!line || line[0] == '\0' || line[0] == '#') {
+ return NULL;
+ }
+
+ // Strip trailing \r if present (CRLF handling)
+ char* work = g_strdup(line);
+ gsize len = strlen(work);
+ if (len > 0 && work[len - 1] == '\r') {
+ work[len - 1] = '\0';
+ len--;
+ }
+ if (len == 0) {
+ g_free(work);
+ return NULL;
+ }
+
+ // UTF-8 validation
+ const gchar* end;
+ if (!g_utf8_validate(work, -1, &end)) {
+ log_warning("flatfile: invalid UTF-8 at byte offset %ld", (long)(end - work));
+ // Attempt Latin-1 fallback
+ gsize br, bw;
+ GError* err = NULL;
+ char* converted = g_convert(work, -1, "UTF-8", "ISO-8859-1", &br, &bw, &err);
+ if (converted) {
+ g_free(work);
+ work = converted;
+ } else {
+ if (err)
+ g_error_free(err);
+ g_free(work);
+ return NULL;
+ }
+ }
+
+ ff_parsed_line_t* result = g_malloc0(sizeof(ff_parsed_line_t));
+
+ // Parse timestamp — everything up to first space followed by '['
+ char* bracket_start = strchr(work, '[');
+ char* first_space = strchr(work, ' ');
+
+ if (bracket_start && first_space && first_space < bracket_start) {
+ // Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
+ result->timestamp_str = g_strndup(work, first_space - work);
+
+ // Parse metadata section [...]
+ const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
+ if (bracket_end) {
+ char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
+
+ // Split by unescaped '|'
+ char** parts = ff_split_meta(meta_content);
+ if (parts) {
+ int i = 0;
+ for (; parts[i]; i++) {
+ if (i == 0) {
+ result->type = g_strdup(parts[i]);
+ } else if (i == 1) {
+ result->enc = g_strdup(parts[i]);
+ } else if (g_str_has_prefix(parts[i], "id:")) {
+ result->stanza_id = ff_unescape_meta_value(parts[i] + 3);
+ } else if (g_str_has_prefix(parts[i], "aid:")) {
+ result->archive_id = ff_unescape_meta_value(parts[i] + 4);
+ } else if (g_str_has_prefix(parts[i], "corrects:")) {
+ result->replace_id = ff_unescape_meta_value(parts[i] + 9);
+ }
+ }
+ g_strfreev(parts);
+ }
+ g_free(meta_content);
+
+ // Parse sender: message after '] '
+ const char* after_meta = bracket_end + 1;
+ if (*after_meta == ' ')
+ after_meta++;
+
+ // Find first *unescaped* ': ' which separates sender from message.
+ const char* colon = ff_find_unescaped_colonspace(after_meta);
+ if (colon) {
+ char* raw_sender = g_strndup(after_meta, colon - after_meta);
+
+ // Split sender into jid/resource, then unescape resource
+ char* slash = strchr(raw_sender, '/');
+ if (slash) {
+ result->from_jid = g_strndup(raw_sender, slash - raw_sender);
+ result->from_resource = ff_unescape_sender_resource(slash + 1);
+ } else {
+ result->from_jid = g_strdup(raw_sender);
+ }
+ g_free(raw_sender);
+
+ result->message = ff_unescape_message(colon + 2);
+ } else {
+ // No ': ' found, treat entire rest as message with unknown sender
+ result->from_jid = g_strdup("unknown");
+ result->message = ff_unescape_message(after_meta);
+ }
+ } else {
+ // No closing bracket — malformed metadata
+ ff_parsed_line_free(result);
+ g_free(work);
+ return NULL;
+ }
+ } else if (first_space) {
+ // Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
+ result->timestamp_str = g_strndup(work, first_space - work);
+ result->type = g_strdup("chat");
+ result->enc = g_strdup("none");
+
+ char* rest = first_space + 1;
+ // Skip " - " if present (chatlog.c format)
+ if (g_str_has_prefix(rest, "- ")) {
+ rest += 2;
+ }
+
+ char* colon = strstr(rest, ": ");
+ if (colon) {
+ char* sender = g_strndup(rest, colon - rest);
+ char* slash = strchr(sender, '/');
+ if (slash) {
+ result->from_jid = g_strndup(sender, slash - sender);
+ result->from_resource = g_strdup(slash + 1);
+ } else {
+ result->from_jid = g_strdup(sender);
+ }
+ g_free(sender);
+ result->message = ff_unescape_message(colon + 2);
+ } else {
+ result->from_jid = g_strdup("unknown");
+ result->message = ff_unescape_message(rest);
+ }
+ } else {
+ // No space at all — can't parse
+ ff_parsed_line_free(result);
+ g_free(work);
+ return NULL;
+ }
+
+ // Parse timestamp
+ result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
+ if (!result->timestamp) {
+ log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
+ ff_parsed_line_free(result);
+ g_free(work);
+ return NULL;
+ }
+
+ // Default type/enc if missing
+ if (!result->type)
+ result->type = g_strdup("chat");
+ if (!result->enc)
+ result->enc = g_strdup("none");
+
+ g_free(work);
+ return result;
+}
+
+// Convert parsed line to ProfMessage
+ProfMessage*
+ff_parsed_to_profmessage(ff_parsed_line_t* pl)
+{
+ ProfMessage* msg = message_init();
+ msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
+ msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
+ msg->plain = g_strdup(pl->message ? pl->message : "");
+ msg->timestamp = g_date_time_ref(pl->timestamp);
+ msg->type = ff_get_message_type_type(pl->type);
+ msg->enc = ff_get_message_enc_type(pl->enc);
+ return msg;
+}
diff --git a/src/database_flatfile_verify.c b/src/database_flatfile_verify.c
new file mode 100644
index 00000000..515b1886
--- /dev/null
+++ b/src/database_flatfile_verify.c
@@ -0,0 +1,378 @@
+/*
+ * database_flatfile_verify.c
+ * vim: expandtab:ts=4:sts=4:sw=4
+ *
+ * Copyright (C) 2026 Profanity Contributors
+ *
+ * This file is part of Profanity.
+ *
+ * Profanity is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Profanity is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Profanity. If not, see .
+ *
+ * Flat-file backend: integrity verification (/history verify).
+ * Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
+ * references, file permissions, BOM, CRLF, UTF-8, control chars.
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "log.h"
+#include "config/files.h"
+#include "database_flatfile.h"
+
+GSList*
+ff_verify_integrity(const gchar* const contact_barejid)
+{
+ GSList* issues = NULL;
+
+ if (!g_flatfile_account_jid) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_ERROR;
+ issue->file = g_strdup("N/A");
+ issue->line = 0;
+ issue->message = g_strdup("Flat-file backend not initialized");
+ issues = g_slist_append(issues, issue);
+ return issues;
+ }
+
+ // If contact specified, verify just that contact; otherwise discover all contacts
+ GSList* contact_dirs = NULL;
+
+ if (contact_barejid) {
+ auto_gchar gchar* cdir = ff_get_contact_dir(contact_barejid);
+ if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
+ contact_dirs = g_slist_append(contact_dirs, g_strdup(cdir));
+ } else {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_INFO;
+ issue->file = g_strdup(contact_barejid);
+ issue->line = 0;
+ issue->message = g_strdup("No log files found for this contact");
+ issues = g_slist_append(issues, issue);
+ return issues;
+ }
+ } else {
+ // Discover all contact directories
+ auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
+ auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
+ auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
+
+ GDir* dir = g_dir_open(base_dir, 0, NULL);
+ if (dir) {
+ const gchar* dname;
+ while ((dname = g_dir_read_name(dir)) != NULL) {
+ char* full = g_strdup_printf("%s/%s", base_dir, dname);
+ if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
+ contact_dirs = g_slist_append(contact_dirs, full);
+ } else {
+ g_free(full);
+ }
+ }
+ g_dir_close(dir);
+ }
+ }
+
+ // Verify each contact directory
+ for (GSList* cd = contact_dirs; cd; cd = cd->next) {
+ const char* cdir_path = cd->data;
+
+ GDir* dir = g_dir_open(cdir_path, 0, NULL);
+ if (!dir)
+ continue;
+
+ GSList* log_files = NULL;
+ const gchar* fname;
+ while ((fname = g_dir_read_name(dir)) != NULL) {
+ if (g_str_has_suffix(fname, ".log")) {
+ log_files = g_slist_insert_sorted(log_files,
+ g_strdup_printf("%s/%s", cdir_path, fname),
+ (GCompareFunc)g_strcmp0);
+ }
+ }
+ g_dir_close(dir);
+
+ GDateTime* prev_file_last_ts = NULL;
+ GHashTable* seen_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
+ GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
+
+ for (GSList* lf = log_files; lf; lf = lf->next) {
+ const char* filepath = lf->data;
+ const char* basename = strrchr(filepath, '/');
+ basename = basename ? basename + 1 : filepath;
+
+ // Check file permissions
+ struct stat st;
+ if (g_stat(filepath, &st) == 0) {
+ if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = 0;
+ issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777);
+ issues = g_slist_append(issues, issue);
+ }
+ }
+
+ FILE* fp = fopen(filepath, "r");
+ if (!fp)
+ continue;
+
+ // BOM check
+ int c1 = fgetc(fp);
+ int c2 = fgetc(fp);
+ int c3 = fgetc(fp);
+ if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_INFO;
+ issue->file = g_strdup(basename);
+ issue->line = 0;
+ issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
+ issues = g_slist_append(issues, issue);
+ } else {
+ fseek(fp, 0, SEEK_SET);
+ }
+
+ char* buf = NULL;
+ int lineno = 0;
+ GDateTime* prev_ts = NULL;
+ GDateTime* first_ts = NULL;
+ GDateTime* last_ts = NULL;
+ gboolean has_crlf = FALSE;
+ gboolean is_empty = TRUE;
+
+ while ((buf = ff_readline(fp, NULL)) != NULL) {
+ lineno++;
+ gsize len = strlen(buf);
+
+ // CRLF check
+ if (len > 0 && buf[len - 1] == '\r') {
+ has_crlf = TRUE;
+ buf[--len] = '\0';
+ }
+
+ // Skip empty lines and comments
+ if (len == 0 || buf[0] == '#') {
+ free(buf);
+ continue;
+ }
+ is_empty = FALSE;
+
+ // UTF-8 validation
+ const gchar* end;
+ if (!g_utf8_validate(buf, -1, &end)) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_ERROR;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf));
+ issues = g_slist_append(issues, issue);
+ free(buf);
+ continue;
+ }
+
+ // Control character check
+ for (gsize i = 0; i < len; i++) {
+ unsigned char ch = (unsigned char)buf[i];
+ if (ch < 0x20 && ch != '\t') {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
+ issues = g_slist_append(issues, issue);
+ break;
+ }
+ }
+
+ // Parse line
+ ff_parsed_line_t* pl = ff_parse_line(buf);
+ if (!pl) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_ERROR;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup("Unparsable line");
+ issues = g_slist_append(issues, issue);
+ free(buf);
+ continue;
+ }
+
+ free(buf); // done with raw line
+
+ if (!first_ts)
+ first_ts = g_date_time_ref(pl->timestamp);
+ if (last_ts)
+ g_date_time_unref(last_ts);
+ last_ts = g_date_time_ref(pl->timestamp);
+
+ // Timestamp order within file
+ if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
+ auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
+ auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
+ issues = g_slist_append(issues, issue);
+ }
+ if (prev_ts)
+ g_date_time_unref(prev_ts);
+ prev_ts = g_date_time_ref(pl->timestamp);
+
+ // Duplicate stanza-id / archive-id
+ if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
+ if (g_hash_table_contains(seen_ids, pl->stanza_id)) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id);
+ issues = g_slist_append(issues, issue);
+ } else {
+ g_hash_table_insert(seen_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
+ }
+ g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
+ }
+ if (pl->archive_id && strlen(pl->archive_id) > 0) {
+ if (g_hash_table_contains(seen_ids, pl->archive_id)) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
+ issues = g_slist_append(issues, issue);
+ } else {
+ g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
+ }
+ }
+
+ ff_parsed_line_free(pl);
+ }
+
+ fclose(fp);
+
+ // CRLF warning for file
+ if (has_crlf) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = 0;
+ issue->message = g_strdup("File uses Windows line endings (CRLF) — consider converting to LF");
+ issues = g_slist_append(issues, issue);
+ }
+
+ // Empty file
+ if (is_empty) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_INFO;
+ issue->file = g_strdup(basename);
+ issue->line = 0;
+ issue->message = g_strdup("File is empty (no message lines)");
+ issues = g_slist_append(issues, issue);
+ }
+
+ // Cross-file timestamp ordering
+ if (first_ts && prev_file_last_ts) {
+ if (g_date_time_compare(first_ts, prev_file_last_ts) < 0) {
+ auto_gchar gchar* ts_first = g_date_time_format_iso8601(first_ts);
+ auto_gchar gchar* ts_prev_last = g_date_time_format_iso8601(prev_file_last_ts);
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_WARNING;
+ issue->file = g_strdup(basename);
+ issue->line = 0;
+ issue->message = g_strdup_printf("First timestamp (%s) is before previous file's last timestamp (%s)",
+ ts_first, ts_prev_last);
+ issues = g_slist_append(issues, issue);
+ }
+ }
+
+ if (prev_file_last_ts)
+ g_date_time_unref(prev_file_last_ts);
+ prev_file_last_ts = last_ts ? g_date_time_ref(last_ts) : NULL;
+
+ if (prev_ts)
+ g_date_time_unref(prev_ts);
+ if (first_ts)
+ g_date_time_unref(first_ts);
+ if (last_ts)
+ g_date_time_unref(last_ts);
+ }
+
+ // Second pass: check LMC references across all files
+ for (GSList* lf = log_files; lf; lf = lf->next) {
+ const char* filepath = lf->data;
+ const char* basename_lmc = strrchr(filepath, '/');
+ basename_lmc = basename_lmc ? basename_lmc + 1 : filepath;
+
+ FILE* fp = fopen(filepath, "r");
+ if (!fp)
+ continue;
+
+ // Skip BOM
+ int b1 = fgetc(fp);
+ int b2 = fgetc(fp);
+ int b3 = fgetc(fp);
+ if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF)) {
+ fseek(fp, 0, SEEK_SET);
+ }
+
+ char* buf = NULL;
+ int lineno = 0;
+ while ((buf = ff_readline(fp, NULL)) != NULL) {
+ lineno++;
+ gsize len = strlen(buf);
+ if (len > 0 && buf[len - 1] == '\r')
+ buf[--len] = '\0';
+ if (len == 0 || buf[0] == '#') {
+ free(buf);
+ continue;
+ }
+
+ ff_parsed_line_t* pl = ff_parse_line(buf);
+ free(buf);
+ if (!pl)
+ continue;
+
+ if (pl->replace_id && strlen(pl->replace_id) > 0) {
+ if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
+ integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
+ issue->level = INTEGRITY_ERROR;
+ issue->file = g_strdup(basename_lmc);
+ issue->line = lineno;
+ issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
+ issues = g_slist_append(issues, issue);
+ }
+ }
+ ff_parsed_line_free(pl);
+ }
+ fclose(fp);
+ }
+
+ if (prev_file_last_ts)
+ g_date_time_unref(prev_file_last_ts);
+ g_hash_table_destroy(seen_ids);
+ g_hash_table_destroy(all_stanza_ids);
+ g_slist_free_full(log_files, g_free);
+ }
+
+ g_slist_free_full(contact_dirs, g_free);
+ return issues;
+}