fix(review): address PR #94 review #37/#38 reviewer comments

Parser (src/database_flatfile_parser.c):
- ff_jid_to_dir: drop GString+step1, in-place mutate str_replace result
- ff_ensure_dir: g_file_test(G_FILE_TEST_IS_SYMLINK) instead of g_lstat
- ff_unescape_*: early-continue for non-escape branch (less nesting)
- ff_readline: strdup("") instead of malloc(1)+'\0' for skip-line return
- ff_write_line: auto_char safe_msg + single fprintf (drop GString full_line)
- ff_parse_line: early return on missing closing bracket;
  extract metadata-parts loop into _ff_parse_meta_parts helper

Verify (src/database_flatfile_verify.c):
- Split monolithic ff_verify_integrity into _collect_contact_dirs,
  _check_permissions, _first_pass, _lmc_pass, _verify_contact_dir
- _issue_new constructor; g_new0 throughout
- Separate seen_stanza_ids and seen_archive_ids tables (no cross-kind
  duplicate confusion)
- ff_skip_bom in second pass (drop manual fgetc x3)
- log_debug when skipping a contact dir without history.log
- Doc comment on ff_verify_integrity in header

SQLite (src/database_sqlite.c):
- Doc block on ChatLogs schema (replaces_db_id <-> replaced_by_db_id
  invariant maintained by AFTER INSERT trigger)
- log_error when _get_db_filename returns NULL during init

Flatfile dispatch (src/database_flatfile.c):
- Replace if/else with is_outgoing ternary for log-path contact pick

Tests:
- 6 new invalid-date parser tests: empty / partial ISO / garbage /
  impossible calendar / negative year / far future timestamp
- New functional test message_db_history_multi_resource verifying
  same-JID-different-resource history rehydration preserves resources
- test_database_stress.c LMC chain loop: auto_char buf, drop redundant
  comment/empty pre-filter (ff_parse_line handles those)
This commit is contained in:
2026-04-30 10:53:03 +03:00
parent 7ba7e53291
commit 8868d58920
12 changed files with 621 additions and 400 deletions

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 == '\\')
*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');
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("");
}
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) {
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)
// =========================================================================
// 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);
} 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)));
}
// =========================================================================
// 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
// 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++;
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'.",
account && account->jid ? account->jid : "(unknown)");
sqlite3_shutdown();
return FALSE;
}
@@ -147,6 +149,28 @@ _sqlite_init(ProfAccount* account)
return TRUE;
}
// 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;
while (1) {
char* buf = ff_readline(fp, NULL);
auto_char char* buf = ff_readline(fp, NULL);
if (!buf)
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),