no-DB mode implementation #94

Manually merged
jabber.developer merged 34 commits from feat/no-db-mode into master 2026-05-05 19:32:42 +00:00
34 changed files with 8800 additions and 711 deletions
Showing only changes of commit 8868d58920 - Show all commits

View File

@@ -373,13 +373,10 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id,
g_date_time_unref(dt);
// Determine which JID to use for the log file path (the "other" party)
const char* contact = NULL;
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0) {
contact = to_barejid;
} else {
contact = from_barejid;
}
gboolean is_outgoing = myjid && myjid->barejid
&& g_strcmp0(from_barejid, myjid->barejid) == 0;
const char* contact = is_outgoing ? to_barejid : from_barejid;
auto_gchar gchar* log_path = ff_get_log_path(contact);

View File

@@ -148,6 +148,17 @@ ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
// --- Integrity verification (database_flatfile_verify.c) ---
// Run integrity checks against one contact's history.log (or every contact
// when contact_barejid == NULL).
//
// Returns a freshly allocated GSList<integrity_issue_t*> reporting:
// - File-level: missing log, BOM, CRLF, empty, wrong permissions
// - Line-level: invalid UTF-8, control characters, unparsable lines,
// timestamps out of order, duplicate stanza-id /
// archive-id (tracked separately)
// - Cross-line: broken `corrects:` LMC references
//
// Callers must free with g_slist_free_full(issues, integrity_issue_free).
GSList* ff_verify_integrity(const gchar* const contact_barejid);
#endif

View File

@@ -98,7 +98,7 @@ ff_get_message_enc_type(const char* const encstr)
// Sanitise a JID for use as a directory name.
// 1. Replace '@' with '_at_'
// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
// 2. Reject / strip path-separator and traversal characters: '/', '\\', '..'
// This prevents a malicious federated JID like "../../../tmp/pwned" from
// escaping the log directory tree.
char*
@@ -107,36 +107,32 @@ ff_jid_to_dir(const char* jid)
if (!jid || jid[0] == '\0')
return NULL;
// Replace '@' first
char* step1 = str_replace(jid, "@", "_at_");
if (!step1)
// Replace '@' with '_at_' (str_replace returns a freshly allocated string)
char* result = str_replace(jid, "@", "_at_");
if (!result)
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);
}
// Replace path-separator characters in place
for (char* p = result; *p; p++) {
if (*p == '/' || *p == '\\')
jabber.developer marked this conversation as resolved Outdated

I don't understand the need for gstring and step1. I propose a simpler solution that might be more efficient, but I might've missed some intricacies:

char* ff_jid_to_dir(const char* jid)
{
    if (!jid || jid[0] == '\0')
        return NULL;
    
    // Replace '@' with "_at_"
    char* result = str_replace(jid, "@", "_at_");
    if (!result)
        return NULL;
    
    // Replace '/', '\\' with '_'
    for (char* p = result; *p; p++) {
        if (*p == '/' || *p == '\\') {
            *p = '_';
        }
    }
    
    // Collapse ".." to "__"
    char* dotdot;
    while ((dotdot = strstr(result, "..")) != NULL) {
        dotdot[0] = '_';
        dotdot[1] = '_';
    }
    
    // Reject empty result
    if (result[0] == '\0') {
        free(result);
        return NULL;
    }
    
    return result;
}
I don't understand the need for `gstring` and `step1`. I propose a simpler solution that might be more efficient, but I might've missed some intricacies: ```c char* ff_jid_to_dir(const char* jid) { if (!jid || jid[0] == '\0') return NULL; // Replace '@' with "_at_" char* result = str_replace(jid, "@", "_at_"); if (!result) return NULL; // Replace '/', '\\' with '_' for (char* p = result; *p; p++) { if (*p == '/' || *p == '\\') { *p = '_'; } } // Collapse ".." to "__" char* dotdot; while ((dotdot = strstr(result, "..")) != NULL) { dotdot[0] = '_'; dotdot[1] = '_'; } // Reject empty result if (result[0] == '\0') { free(result); return NULL; } return result; } ```

Corrected

Corrected
*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);
free(result);
return NULL;
}
return result;
// Callers free via g_free, str_replace allocates via malloc — handover.
char* gresult = g_strdup(result);
free(result);
return gresult;
}
// Get the base directory for a contact's logs:
@@ -172,15 +168,12 @@ ff_get_log_path(const char* contact_barejid)
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_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
log_error("flatfile: directory path is a symlink, refusing: %s", path);
return FALSE;
}
if (g_file_test(path, G_FILE_TEST_IS_DIR))
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;
@@ -231,25 +224,25 @@ ff_unescape_message(const char* 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 {
if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
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;
}
}
return g_string_free(out, FALSE);
@@ -296,31 +289,31 @@ ff_unescape_meta_value(const char* 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 {
if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
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');
jabber.developer marked this conversation as resolved Outdated

early exit here would lower the nesting and potentially improve readability
something like:
if(!condition) {
g_string_append_c(out, *p);
continue;
}

early exit here would lower the nesting and potentially improve readability something like: if(!condition) { g_string_append_c(out, *p); continue; }

Corrected

Corrected
break;
default:
g_string_append_c(out, '\\');
g_string_append_c(out, *p);
break;
}
}
return g_string_free(out, FALSE);
@@ -376,11 +369,8 @@ ff_readline(FILE* fp, gboolean* truncated)
if (truncated)
*truncated = FALSE;
// Return empty string so caller's loop continues (parse will reject it).
// Use malloc() so callers can always use free() consistently.
char* empty = malloc(1);
if (empty)
empty[0] = '\0';
return empty;
// strdup() so callers can always free() consistently.
return strdup("");
}
jabber.developer marked this conversation as resolved Outdated

nit: Why not strdup("")?

nit: Why not `strdup("")`?

Corrected

Corrected
if (truncated)
*truncated = FALSE;
@@ -465,21 +455,14 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
}
// Escape message body to prevent log injection
char* safe_msg = ff_escape_message(message_text);
auto_char 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;
size_t written = fwrite(full_line->str, 1, to_write, fp);
if (written != to_write) {
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
int ret = fprintf(fp, "%s %s %s: %s\n",
timestamp, meta->str, sender->str, safe_msg);
if (ret < 0) {
jabber.developer marked this conversation as resolved Outdated

why not auto_char?

why not auto_char?

Corrected

Corrected
log_error("flatfile: fprintf failed (errno=%d)", errno);
}
g_string_free(full_line, TRUE);
g_free(safe_msg);
g_string_free(meta, TRUE);
g_string_free(sender, TRUE);
}
@@ -554,19 +537,19 @@ ff_unescape_sender_resource(const char* 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);
}
if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
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);
}
}
@@ -598,6 +581,33 @@ ff_parsed_line_free(ff_parsed_line_t* pl)
g_free(pl);
}
// Populate the parsed-line metadata fields from the pipe-split parts[]
// (the contents of a "[...]" section). Index 0 is type, index 1 is enc;
// remaining parts are key:value pairs (id:, aid:, corrects:, to:, to_res:, read:).
static void
_ff_parse_meta_parts(char** parts, ff_parsed_line_t* out)
{
for (int i = 0; parts[i]; i++) {
if (i == 0) {
out->type = g_strdup(parts[i]);
} else if (i == 1) {
out->enc = g_strdup(parts[i]);
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
out->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
out->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
} else if (g_str_has_prefix(parts[i], "corrects:")) {
out->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
} else if (g_str_has_prefix(parts[i], "to:")) {
out->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
} else if (g_str_has_prefix(parts[i], "to_res:")) {
out->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
} else if (g_str_has_prefix(parts[i], "read:")) {
out->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
}
}
}
// Parse a single line. Returns NULL on parse failure.
// Line format: {timestamp} [{metadata}] {sender}: {message}
ff_parsed_line_t*
@@ -652,68 +662,45 @@ ff_parse_line(const char* line)
// 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], FF_META_PREFIX_ID)) {
result->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
result->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
} else if (g_str_has_prefix(parts[i], "corrects:")) {
result->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
} else if (g_str_has_prefix(parts[i], "to:")) {
result->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
} else if (g_str_has_prefix(parts[i], "to_res:")) {
result->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
} else if (g_str_has_prefix(parts[i], "read:")) {
result->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
}
}
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 {
if (!bracket_end) {
// No closing bracket — malformed metadata
ff_parsed_line_free(result);
g_free(work);
return NULL;
}
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
char** parts = ff_split_meta(meta_content);
if (parts) {
_ff_parse_meta_parts(parts, result);
g_strfreev(parts);
}
// 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) {
auto_gchar gchar* 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);
}
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 if (first_space) {
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
result->timestamp_str = g_strndup(work, first_space - work);

View File

@@ -8,8 +8,26 @@
* vim: expandtab:ts=4:sts=4:sw=4
*
* Flat-file backend: integrity verification (/history verify).
* Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
* references, file permissions, BOM, CRLF, UTF-8, control chars.
*
* High-level flow (see ff_verify_integrity):
* 1. Resolve target contact directories — either a single contact (when
* contact_barejid != NULL) or every contact directory under
* flatlog/<account>/.
* 2. For each contact's history.log:
* - Check file permissions (warn if not 0600).
* - First pass: line-by-line UTF-8 / control-char / parser /
* duplicate-id / timestamp-ordering checks. Records every
* stanza-id and archive-id seen so the second pass can resolve
* LMC references. stanza-id and archive-id are tracked in
* *separate* hash tables — duplicate detection is reported
* per id-kind to avoid spurious cross-kind warnings.
* - Second pass: re-read the file and verify each `corrects:` link
* points at a stanza-id that was seen in pass 1.
* 3. Aggregate every issue into a GSList<integrity_issue_t> and return
* it to the caller, ready for /history verify rendering.
*
* The file is split into small helpers so each pass can be understood in
* isolation; ff_verify_integrity itself is just orchestration.
*/
#include "config.h"
@@ -25,22 +43,32 @@
#include "config/files.h"
#include "database_flatfile.h"
GSList*
ff_verify_integrity(const gchar* const contact_barejid)
// =========================================================================
jabber.developer marked this conversation as resolved Outdated

Here and everywhere else, we can use new style for consistency with g_new0

Here and everywhere else, we can use new style for consistency with `g_new0`

Corrected

Corrected
// Issue construction
// =========================================================================
static integrity_issue_t*
_issue_new(integrity_level_t level, const char* file, int line, char* message)
{
GSList* issues = NULL;
integrity_issue_t* issue = g_new0(integrity_issue_t, 1);
issue->level = level;
issue->file = g_strdup(file ? file : "");
issue->line = line;
issue->message = message; // takes ownership of the (g_strdup'd) message
return issue;
}
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_prepend(issues, issue);
return issues;
}
// =========================================================================
// Contact discovery
// =========================================================================
// If contact specified, verify just that contact; otherwise discover all contacts
// Build the list of contact directories to verify. If contact_barejid is
// supplied, the list is either a singleton or empty (and an INFO issue is
// appended explaining no logs exist for that contact). Otherwise, every
// subdirectory of flatlog/<account>/ is enumerated.
static GSList*
_collect_contact_dirs(const gchar* const contact_barejid, GSList** issues)
{
GSList* contact_dirs = NULL;
if (contact_barejid) {
@@ -48,243 +76,283 @@ ff_verify_integrity(const gchar* const contact_barejid)
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
contact_dirs = g_slist_prepend(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_prepend(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_prepend(contact_dirs, full);
} else {
g_free(full);
}
}
g_dir_close(dir);
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
g_strdup("No log files found for this contact")));
}
return contact_dirs;
}
// Verify each contact directory (single history.log per contact)
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
const char* cdir_path = cd->data;
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);
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
if (!g_file_test(filepath, G_FILE_TEST_EXISTS))
continue;
GDir* dir = g_dir_open(base_dir, 0, NULL);
if (!dir)
return NULL;
const char* basename = "history.log";
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_prepend(contact_dirs, full);
jabber.developer marked this conversation as resolved Outdated

I am not sure if it's ok to just silently continue in this place. It deserves at the very least debug log.

I am not sure if it's ok to just silently `continue` in this place. It deserves at the very least debug log.

Corrected

Corrected
} else {
g_free(full);
}
}
g_dir_close(dir);
return contact_dirs;
}
// 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_prepend(issues, issue);
}
// =========================================================================
// Permission check
// =========================================================================
static void
_check_permissions(const char* filepath, const char* basename, GSList** issues)
{
struct stat st;
if (g_stat(filepath, &st) != 0)
return;
if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
return;
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
st.st_mode & 0777)));
}
jabber.developer marked this conversation as resolved Outdated

Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in database_flatfile.c:163

Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in `database_flatfile.c:163`

Corrected

Corrected
// =========================================================================
// First pass: per-line content checks + id collection
// =========================================================================
//
// For every non-empty, non-comment line:
// - validate UTF-8
// - flag control characters
// - try to parse via ff_parse_line(); if that fails, record the lineno
// - check timestamp monotonicity
// - flag duplicate stanza-id and duplicate archive-id (separate tables)
// - record every stanza-id encountered for LMC pass 2
//
// Also detects: BOM (informational), CRLF line endings (warning), empty
jabber.developer marked this conversation as resolved Outdated

I am not sure if the overlap between stanza_ids and archive_ids is a valid approach. We might want to separate them in two distinct tables.

I am not sure if the overlap between `stanza_ids` and `archive_ids` is a valid approach. We might want to separate them in two distinct tables.

Postponed, architecture only question

Postponed, architecture only question
// file (informational).
static void
_first_pass(FILE* fp, const char* basename,
GHashTable* seen_stanza_ids, GHashTable* seen_archive_ids,
GHashTable* all_stanza_ids, GSList** issues)
{
if (ff_skip_bom(fp)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
}
char* buf = NULL;
int lineno = 0;
GDateTime* prev_ts = NULL;
gboolean has_crlf = FALSE;
gboolean is_empty = TRUE;
while ((buf = ff_readline(fp, NULL)) != NULL) {
lineno++;
gsize len = strlen(buf);
if (len > 0 && buf[len - 1] == '\r') {
has_crlf = TRUE;
buf[--len] = '\0';
}
FILE* fp = fopen(filepath, "r");
if (!fp)
continue;
// BOM check
if (ff_skip_bom(fp)) {
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_prepend(issues, issue);
}
char* buf = NULL;
int lineno = 0;
GDateTime* prev_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);
gboolean has_crlf = FALSE;
gboolean is_empty = TRUE;
while ((buf = ff_readline(fp, NULL)) != NULL) {
lineno++;
gsize len = strlen(buf);
if (len > 0 && buf[len - 1] == '\r') {
has_crlf = TRUE;
buf[--len] = '\0';
}
if (len == 0 || buf[0] == '#') {
free(buf);
continue;
}
is_empty = FALSE;
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_prepend(issues, issue);
free(buf);
continue;
}
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_prepend(issues, issue);
break;
}
}
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_prepend(issues, issue);
free(buf);
continue;
}
if (len == 0 || buf[0] == '#') {
free(buf);
continue;
}
is_empty = FALSE;
// Timestamp ordering
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_prepend(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_prepend(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_prepend(issues, issue);
} else {
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
}
}
ff_parsed_line_free(pl);
const gchar* end;
if (!g_utf8_validate(buf, -1, &end)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
free(buf);
continue;
}
fclose(fp);
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_prepend(issues, issue);
}
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_prepend(issues, issue);
}
// Second pass: check LMC references
fp = fopen(filepath, "r");
if (fp) {
int b1 = fgetc(fp);
int b2 = fgetc(fp);
int b3 = fgetc(fp);
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF))
fseek(fp, 0, SEEK_SET);
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);
issue->line = lineno;
issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
issues = g_slist_prepend(issues, issue);
}
}
ff_parsed_line_free(pl);
for (gsize i = 0; i < len; i++) {
unsigned char ch = (unsigned char)buf[i];
if (ch < 0x20 && ch != '\t') {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Contains control character 0x%02x", ch)));
break;
}
fclose(fp);
}
ff_parsed_line_t* pl = ff_parse_line(buf);
if (!pl) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup("Unparsable line")));
free(buf);
continue;
}
free(buf);
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);
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
}
if (prev_ts)
g_date_time_unref(prev_ts);
g_hash_table_destroy(seen_ids);
g_hash_table_destroy(all_stanza_ids);
prev_ts = g_date_time_ref(pl->timestamp);
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
} else {
g_hash_table_insert(seen_stanza_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 && pl->archive_id[0] != '\0') {
if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
} else {
g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
}
}
ff_parsed_line_free(pl);
}
if (prev_ts)
g_date_time_unref(prev_ts);
if (has_crlf) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, 0,
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
}
if (is_empty) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_INFO, basename, 0,
g_strdup("File is empty (no message lines)")));
}
}
// =========================================================================
// Second pass: LMC reference resolution
// =========================================================================
//
// Walks the file again, and for every line that carries a `corrects:` link,
// checks that the referenced stanza-id was seen in pass 1. Broken references
// surface as INTEGRITY_ERROR.
static void
_lmc_pass(FILE* fp, const char* basename, GHashTable* all_stanza_ids, GSList** issues)
{
ff_skip_bom(fp);
char* buf = NULL;
int lineno = 0;
while ((buf = ff_readline(fp, NULL)) != NULL) {
lineno++;
jabber.developer marked this conversation as resolved Outdated

Please, extract bom-check to a different method.

Please, extract bom-check to a different method.

Corrected

Corrected
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 && pl->replace_id[0] != '\0'
&& !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_ERROR, basename, lineno,
g_strdup_printf("Broken correction reference: corrects:%s not found",
pl->replace_id)));
}
ff_parsed_line_free(pl);
}
}
// =========================================================================
// Per-contact verification
// =========================================================================
static void
_verify_contact_dir(const char* cdir_path, GSList** issues)
{
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
if (!g_file_test(filepath, G_FILE_TEST_EXISTS)) {
log_debug("flatfile verify: skipping %s (no history.log)", cdir_path);
return;
}
const char* basename = "history.log";
_check_permissions(filepath, basename, issues);
FILE* fp = fopen(filepath, "r");
if (!fp) {
log_warning("flatfile verify: cannot open %s for reading", filepath);
return;
}
// Separate tables by id-kind: a stanza-id and an archive-id can legitimately
// share the same string without that being a duplicate.
GHashTable* seen_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
GHashTable* seen_archive_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);
_first_pass(fp, basename, seen_stanza_ids, seen_archive_ids, all_stanza_ids, issues);
fclose(fp);
// Second pass: LMC references
fp = fopen(filepath, "r");
if (fp) {
_lmc_pass(fp, basename, all_stanza_ids, issues);
fclose(fp);
}
g_hash_table_destroy(seen_stanza_ids);
g_hash_table_destroy(seen_archive_ids);
g_hash_table_destroy(all_stanza_ids);
}
// =========================================================================
// Public entry point
// =========================================================================
GSList*
ff_verify_integrity(const gchar* const contact_barejid)
{
GSList* issues = NULL;
if (!g_flatfile_account_jid) {
issues = g_slist_prepend(issues,
_issue_new(INTEGRITY_ERROR, "N/A", 0,
g_strdup("Flat-file backend not initialized")));
return issues;
}
GSList* contact_dirs = _collect_contact_dirs(contact_barejid, &issues);
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
_verify_contact_dir((const char*)cd->data, &issues);
}
g_slist_free_full(contact_dirs, g_free);

View File

@@ -128,6 +128,8 @@ _sqlite_init(ProfAccount* account)
auto_char char* filename = _get_db_filename(account);
if (!filename) {
log_error("Error initializing SQLite database: could not derive chatlog.db path for account '%s'.",
jabber.developer marked this conversation as resolved Outdated

We should add error logging here.

We should add error logging here.

Corrected

Corrected
account && account->jid ? account->jid : "(unknown)");
sqlite3_shutdown();
return FALSE;
}
@@ -147,6 +149,28 @@ _sqlite_init(ProfAccount* account)
return TRUE;
}
jabber.developer marked this conversation as resolved Outdated

Here and in other places, can we return necessary documentation detailing DB fields, decisions, and other useful points?

Here and in other places, can we return necessary documentation detailing DB fields, decisions, and other useful points?

Corrected

Corrected
// ChatLogs schema overview:
// id AUTOINCREMENT primary key. Used internally as the
// anchor for LMC (Last Message Correction) chains.
// from_jid/to_jid Bare JIDs (NOT NULL). Used for window resolution.
// from_resource Resource of the sender (NULLable for legacy rows).
// to_resource Resource of the recipient (NULLable).
// message Plaintext after decryption / format normalisation.
// timestamp ISO-8601 (UTC). Indexed for range queries.
// type "chat" | "muc" | "mucpm" (mirrors prof_msg_type_t).
// stanza_id XEP-0359 unique-and-stable id, used for dedup +
// LMC sender lookup.
// archive_id MAM (XEP-0313) archive id; dedupes rebroadcast.
// encryption "none" | "omemo" | "otr" | "pgp" | "ox"
// (mirrors prof_enc_t).
// marked_read 0/1 — for unread-message badge bookkeeping.
// replace_id XEP-0308 message-correction stanza-id of the
// original message this row replaces.
// replaces_db_id Local FK back-link: id of the row this row
// corrects. Set on insert when replace_id resolves.
// replaced_by_db_id Inverse FK: id of the most recent correction.
// Maintained by the AFTER INSERT trigger below so
// readers can follow the chain forward in O(1).
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
"`from_jid` TEXT NOT NULL, "

View File

@@ -234,6 +234,7 @@ main(int argc, char* argv[])
PROF_FUNC_TEST(message_db_history_service_chars),
PROF_FUNC_TEST(message_db_history_verify),
PROF_FUNC_TEST(message_db_history_lmc),
PROF_FUNC_TEST(message_db_history_multi_resource),
/* Basic message send/receive */
PROF_FUNC_TEST(message_send),

View File

@@ -472,3 +472,67 @@ message_db_history_lmc(void** state)
assert_false(prof_output_exact("lmc-before-correction-aaa"));
prof_timeout_reset();
}
/*
* Test: messages from the SAME bare JID but with DIFFERENT resources are
* stored together in one contact's history AND remain distinguishable on
* reopen — the resource part of the sender JID must be preserved per row,
* not collapsed.
*
* Flow:
* 1. /history on
* 2. buddy1@localhost/phone sends msg A
* 3. buddy1@localhost/laptop sends msg B
* 4. buddy1@localhost/tablet sends msg C
* 5. Close the auto-opened chat window so its in-memory buffer is gone.
* 6. /msg buddy1@localhost — chatwin_new() rehydrates from DB.
* 7. All three bodies must reappear, AND the resource markers
* "/phone", "/laptop", "/tablet" must each be visible on at least one
* history line (PREF_RESOURCE_MESSAGE is on by default in the test
* profile, so the renderer prefixes each line with "Buddy1/<res>").
*/
void
message_db_history_multi_resource(void** state)
{
prof_connect();
prof_input("/history on");
assert_true(prof_output_regex("Chat history enabled\\."));
stbbr_send(
"<message id='res-multi-A' to='stabber@localhost' "
"from='buddy1@localhost/phone' type='chat'>"
"<body>multi-resource-A-from-phone</body>"
"</message>");
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
stbbr_send(
"<message id='res-multi-B' to='stabber@localhost' "
"from='buddy1@localhost/laptop' type='chat'>"
"<body>multi-resource-B-from-laptop</body>"
"</message>");
assert_true(prof_output_exact("<< chat message: Buddy1/laptop (win 2)"));
stbbr_send(
"<message id='res-multi-C' to='stabber@localhost' "
"from='buddy1@localhost/tablet' type='chat'>"
"<body>multi-resource-C-from-tablet</body>"
"</message>");
assert_true(prof_output_exact("<< chat message: Buddy1/tablet (win 2)"));
prof_input("/close 2");
assert_true(prof_output_exact("Closed window 2"));
prof_input("/msg buddy1@localhost");
/* All three bodies must be present in history (single-contact aggregation) */
assert_true(prof_output_exact("multi-resource-A-from-phone"));
assert_true(prof_output_exact("multi-resource-B-from-laptop"));
assert_true(prof_output_exact("multi-resource-C-from-tablet"));
/* Each resource must remain identifiable on rehydrate — proves we don't
* lose per-row resource info when loading the same JID's chat back. */
assert_true(prof_output_regex("Buddy1/phone"));
assert_true(prof_output_regex("Buddy1/laptop"));
assert_true(prof_output_regex("Buddy1/tablet"));
}

View File

@@ -10,3 +10,4 @@ void message_db_history_newline(void** state);
void message_db_history_service_chars(void** state);
void message_db_history_verify(void** state);
void message_db_history_lmc(void** state);
void message_db_history_multi_resource(void** state);

View File

@@ -500,6 +500,66 @@ test_ff_parse_line_invalid_timestamp(void** state)
assert_null(pl);
}
void
test_ff_parse_line_empty_timestamp(void** state)
{
/* The space before "[" makes the timestamp empty -> g_date_time_new_from_iso8601
must reject it. */
ff_parsed_line_t* pl = ff_parse_line(
" [chat|none] a@b.com: msg");
assert_null(pl);
}
void
test_ff_parse_line_partial_iso_timestamp(void** state)
{
/* Year-only or yyyy-mm without time portion is not ISO-8601 enough for
g_date_time_new_from_iso8601 — must fail rather than silently succeed. */
assert_null(ff_parse_line("2025 [chat|none] a@b.com: msg"));
assert_null(ff_parse_line("2025-06-15 [chat|none] a@b.com: msg"));
}
void
test_ff_parse_line_garbage_timestamp(void** state)
{
/* Random characters where timestamp should be — parse failure, no segfault. */
assert_null(ff_parse_line("@@@@@@ [chat|none] a@b.com: msg"));
assert_null(ff_parse_line("12345 [chat|none] a@b.com: msg"));
}
void
test_ff_parse_line_impossible_calendar_date(void** state)
{
/* Day 32 / Month 13 — well-formed ISO syntax but impossible date,
g_date_time_new_from_iso8601 returns NULL. */
assert_null(ff_parse_line(
"2025-13-01T12:00:00Z [chat|none] a@b.com: msg"));
assert_null(ff_parse_line(
"2025-06-32T12:00:00Z [chat|none] a@b.com: msg"));
assert_null(ff_parse_line(
"2025-06-15T25:00:00Z [chat|none] a@b.com: msg"));
}
void
test_ff_parse_line_negative_year_timestamp(void** state)
{
/* Negative year is not parseable by g_date_time_new_from_iso8601. */
assert_null(ff_parse_line(
"-0001-06-15T12:00:00Z [chat|none] a@b.com: msg"));
}
void
test_ff_parse_line_far_future_timestamp(void** state)
{
/* Year 9999 is valid ISO-8601 and should parse — sanity check that we
don't artificially reject valid far-future dates. */
ff_parsed_line_t* pl = ff_parse_line(
"9999-12-31T23:59:59Z [chat|none] a@b.com: hi");
assert_non_null(pl);
assert_non_null(pl->timestamp);
ff_parsed_line_free(pl);
}
/* ================================================================
* Type / enc conversion round-trip
* ================================================================ */

View File

@@ -34,6 +34,12 @@ void test_ff_parse_line_comment(void** state);
void test_ff_parse_line_legacy_format(void** state);
void test_ff_parse_line_no_metadata(void** state);
void test_ff_parse_line_invalid_timestamp(void** state);
void test_ff_parse_line_empty_timestamp(void** state);
void test_ff_parse_line_partial_iso_timestamp(void** state);
void test_ff_parse_line_garbage_timestamp(void** state);
void test_ff_parse_line_impossible_calendar_date(void** state);
void test_ff_parse_line_negative_year_timestamp(void** state);
void test_ff_parse_line_far_future_timestamp(void** state);
/* type/enc conversion round-trip */
void test_ff_type_str_roundtrip(void** state);

View File

@@ -1106,15 +1106,11 @@ test_stress_lmc_many_corrections(void** state)
int corrections_found = 0;
jabber.developer marked this conversation as resolved
Review

Maybe auto_char, if common.h import is allowed?

Maybe `auto_char`, if `common.h` import is allowed?

Corrected

Corrected
while (1) {
char* buf = ff_readline(fp, NULL);
auto_char char* buf = ff_readline(fp, NULL);
if (!buf)
Review

Here and everywhere else: no need for this check, as it will already be done within the ff_parse_line.

Here and everywhere else: no need for this check, as it will already be done within the `ff_parse_line`.

Corrected

Corrected
break;
if (buf[0] == '#' || buf[0] == '\0') {
free(buf);
continue;
}
// ff_parse_line already rejects empty/comment lines, no need to pre-filter.
ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) {
total_parsed++;
if (pl->replace_id && strlen(pl->replace_id) > 0)

View File

@@ -692,6 +692,12 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ff_parse_line_legacy_format),
cmocka_unit_test(test_ff_parse_line_no_metadata),
cmocka_unit_test(test_ff_parse_line_invalid_timestamp),
cmocka_unit_test(test_ff_parse_line_empty_timestamp),
cmocka_unit_test(test_ff_parse_line_partial_iso_timestamp),
cmocka_unit_test(test_ff_parse_line_garbage_timestamp),
cmocka_unit_test(test_ff_parse_line_impossible_calendar_date),
cmocka_unit_test(test_ff_parse_line_negative_year_timestamp),
cmocka_unit_test(test_ff_parse_line_far_future_timestamp),
// Type/enc round-trip
cmocka_unit_test(test_ff_type_str_roundtrip),