From 8868d589200639b2bd4e38d066fa7c9132beea4a Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Thu, 30 Apr 2026 10:53:03 +0300 Subject: [PATCH] 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) --- src/database_flatfile.c | 9 +- src/database_flatfile.h | 11 + src/database_flatfile_parser.c | 293 ++++++------- src/database_flatfile_verify.c | 538 +++++++++++++----------- src/database_sqlite.c | 24 ++ tests/functionaltests/functionaltests.c | 1 + tests/functionaltests/test_history.c | 64 +++ tests/functionaltests/test_history.h | 1 + tests/unittests/test_database_export.c | 60 +++ tests/unittests/test_database_export.h | 6 + tests/unittests/test_database_stress.c | 8 +- tests/unittests/unittests.c | 6 + 12 files changed, 621 insertions(+), 400 deletions(-) diff --git a/src/database_flatfile.c b/src/database_flatfile.c index 29f700bf..395ab5c5 100644 --- a/src/database_flatfile.c +++ b/src/database_flatfile.c @@ -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); diff --git a/src/database_flatfile.h b/src/database_flatfile.h index e447d515..c1e50983 100644 --- a/src/database_flatfile.h +++ b/src/database_flatfile.h @@ -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 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 diff --git a/src/database_flatfile_parser.c b/src/database_flatfile_parser.c index e800679e..53d2a340 100644 --- a/src/database_flatfile_parser.c +++ b/src/database_flatfile_parser.c @@ -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); diff --git a/src/database_flatfile_verify.c b/src/database_flatfile_verify.c index 1d514645..8a136b6a 100644 --- a/src/database_flatfile_verify.c +++ b/src/database_flatfile_verify.c @@ -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//. + * 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 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// 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); diff --git a/src/database_sqlite.c b/src/database_sqlite.c index 82d2b05c..3013655a 100644 --- a/src/database_sqlite.c +++ b/src/database_sqlite.c @@ -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, " diff --git a/tests/functionaltests/functionaltests.c b/tests/functionaltests/functionaltests.c index 0b871273..36d5c664 100644 --- a/tests/functionaltests/functionaltests.c +++ b/tests/functionaltests/functionaltests.c @@ -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), diff --git a/tests/functionaltests/test_history.c b/tests/functionaltests/test_history.c index 61b2dfba..72553e14 100644 --- a/tests/functionaltests/test_history.c +++ b/tests/functionaltests/test_history.c @@ -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/"). + */ +void +message_db_history_multi_resource(void** state) +{ + prof_connect(); + + prof_input("/history on"); + assert_true(prof_output_regex("Chat history enabled\\.")); + + stbbr_send( + "" + "multi-resource-A-from-phone" + ""); + assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)")); + + stbbr_send( + "" + "multi-resource-B-from-laptop" + ""); + assert_true(prof_output_exact("<< chat message: Buddy1/laptop (win 2)")); + + stbbr_send( + "" + "multi-resource-C-from-tablet" + ""); + 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")); +} diff --git a/tests/functionaltests/test_history.h b/tests/functionaltests/test_history.h index 14fc6cd2..3745f98a 100644 --- a/tests/functionaltests/test_history.h +++ b/tests/functionaltests/test_history.h @@ -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); diff --git a/tests/unittests/test_database_export.c b/tests/unittests/test_database_export.c index 465e037d..6f1a71cf 100644 --- a/tests/unittests/test_database_export.c +++ b/tests/unittests/test_database_export.c @@ -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 * ================================================================ */ diff --git a/tests/unittests/test_database_export.h b/tests/unittests/test_database_export.h index f1b91e60..92fed72d 100644 --- a/tests/unittests/test_database_export.h +++ b/tests/unittests/test_database_export.h @@ -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); diff --git a/tests/unittests/test_database_stress.c b/tests/unittests/test_database_stress.c index 59ab981d..a1c614a4 100644 --- a/tests/unittests/test_database_stress.c +++ b/tests/unittests/test_database_stress.c @@ -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) diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index b1af7162..cf5906fa 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -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),