From 786117cf620016f1cdabecbf34059b233989422e Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Wed, 18 Feb 2026 17:17:30 +0300 Subject: [PATCH] refactor: split database_flatfile.c into functional modules Split the monolithic database_flatfile.c into: - database_flatfile.h: internal header with shared types and prototypes - database_flatfile_parser.c: parsing, escaping, IO helpers - database_flatfile_verify.c: integrity verification logic - database_flatfile.c: core backend (init, write, read, LMC, vtable) --- Makefile.am | 4 +- src/database_flatfile.c | 1175 ++------------------------------ src/database_flatfile.h | 110 +++ src/database_flatfile_parser.c | 752 ++++++++++++++++++++ src/database_flatfile_verify.c | 378 ++++++++++ 5 files changed, 1306 insertions(+), 1113 deletions(-) create mode 100644 src/database_flatfile.h create mode 100644 src/database_flatfile_parser.c create mode 100644 src/database_flatfile_verify.c 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; +}