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); g_date_time_unref(dt);
// Determine which JID to use for the log file path (the "other" party) // Determine which JID to use for the log file path (the "other" party)
const char* contact = NULL;
const Jid* myjid = connection_get_jid(); const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0) { gboolean is_outgoing = myjid && myjid->barejid
contact = to_barejid; && g_strcmp0(from_barejid, myjid->barejid) == 0;
} else { const char* contact = is_outgoing ? to_barejid : from_barejid;
contact = from_barejid;
}
auto_gchar gchar* log_path = ff_get_log_path(contact); 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) --- // --- 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); GSList* ff_verify_integrity(const gchar* const contact_barejid);
#endif #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. // Sanitise a JID for use as a directory name.
// 1. Replace '@' with '_at_' // 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 // This prevents a malicious federated JID like "../../../tmp/pwned" from
// escaping the log directory tree. // escaping the log directory tree.
char* char*
@@ -107,36 +107,32 @@ ff_jid_to_dir(const char* jid)
if (!jid || jid[0] == '\0') if (!jid || jid[0] == '\0')
return NULL; return NULL;
// Replace '@' first // Replace '@' with '_at_' (str_replace returns a freshly allocated string)
char* step1 = str_replace(jid, "@", "_at_"); char* result = str_replace(jid, "@", "_at_");
if (!step1) if (!result)
return NULL; return NULL;
// Replace '/' and '\\' with '_' to prevent path traversal // Replace path-separator characters in place
GString* out = g_string_sized_new(strlen(step1)); for (char* p = result; *p; p++) {
for (const char* p = step1; *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
if (*p == '/' || *p == '\\') { *p = '_';
g_string_append_c(out, '_');
} else {
g_string_append_c(out, *p);
} }
}
free(step1);
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders) // Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
char* result = g_string_free(out, FALSE);
char* dotdot; char* dotdot;
while ((dotdot = strstr(result, "..")) != NULL) { while ((dotdot = strstr(result, "..")) != NULL) {
dotdot[0] = '_'; dotdot[0] = '_';
dotdot[1] = '_'; dotdot[1] = '_';
} }
// Reject empty result
if (result[0] == '\0') { if (result[0] == '\0') {
g_free(result); free(result);
return NULL; 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: // Get the base directory for a contact's logs:
@@ -172,15 +168,12 @@ ff_get_log_path(const char* contact_barejid)
gboolean gboolean
ff_ensure_dir(const char* path) ff_ensure_dir(const char* path)
{ {
if (g_file_test(path, G_FILE_TEST_IS_DIR)) { if (g_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
// 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); log_error("flatfile: directory path is a symlink, refusing: %s", path);
return FALSE; return FALSE;
} }
if (g_file_test(path, G_FILE_TEST_IS_DIR))
return TRUE; return TRUE;
}
if (g_mkdir_with_parents(path, S_IRWXU) != 0) { if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno); log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
return FALSE; return FALSE;
@@ -231,7 +224,10 @@ ff_unescape_message(const char* text)
return g_strdup(""); return g_strdup("");
GString* out = g_string_sized_new(strlen(text)); GString* out = g_string_sized_new(strlen(text));
for (const char* p = text; *p; p++) { for (const char* p = text; *p; p++) {
if (*p == '\\' && *(p + 1)) { if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
p++; p++;
switch (*p) { switch (*p) {
case '\\': case '\\':
@@ -248,9 +244,6 @@ ff_unescape_message(const char* text)
g_string_append_c(out, *p); g_string_append_c(out, *p);
break; break;
} }
} else {
g_string_append_c(out, *p);
}
} }
return g_string_free(out, FALSE); return g_string_free(out, FALSE);
} }
@@ -296,7 +289,10 @@ ff_unescape_meta_value(const char* val)
return NULL; return NULL;
GString* out = g_string_sized_new(strlen(val)); GString* out = g_string_sized_new(strlen(val));
for (const char* p = val; *p; p++) { for (const char* p = val; *p; p++) {
if (*p == '\\' && *(p + 1)) { if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
p++; p++;
switch (*p) { switch (*p) {
case '|': case '|':
@@ -319,9 +315,6 @@ ff_unescape_meta_value(const char* val)
g_string_append_c(out, *p); g_string_append_c(out, *p);
break; break;
} }
} else {
g_string_append_c(out, *p);
}
} }
return g_string_free(out, FALSE); return g_string_free(out, FALSE);
} }
@@ -376,11 +369,8 @@ ff_readline(FILE* fp, gboolean* truncated)
if (truncated) if (truncated)
*truncated = FALSE; *truncated = FALSE;
// Return empty string so caller's loop continues (parse will reject it). // Return empty string so caller's loop continues (parse will reject it).
// Use malloc() so callers can always use free() consistently. // strdup() so callers can always free() consistently.
char* empty = malloc(1); return strdup("");
if (empty)
empty[0] = '\0';
return empty;
} }
jabber.developer marked this conversation as resolved Outdated

nit: Why not strdup("")?

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

Corrected

Corrected
if (truncated) if (truncated)
*truncated = FALSE; *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 // 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() int ret = fprintf(fp, "%s %s %s: %s\n",
GString* full_line = g_string_new(NULL);
g_string_printf(full_line, "%s %s %s: %s\n",
timestamp, meta->str, sender->str, safe_msg); 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
size_t to_write = full_line->len; log_error("flatfile: fprintf failed (errno=%d)", errno);
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);
} }
g_string_free(full_line, TRUE);
g_free(safe_msg);
g_string_free(meta, TRUE); g_string_free(meta, TRUE);
g_string_free(sender, TRUE); g_string_free(sender, TRUE);
} }
@@ -554,7 +537,10 @@ ff_unescape_sender_resource(const char* res)
return NULL; return NULL;
GString* out = g_string_sized_new(strlen(res)); GString* out = g_string_sized_new(strlen(res));
for (const char* p = res; *p; p++) { for (const char* p = res; *p; p++) {
if (*p == '\\' && *(p + 1)) { if (*p != '\\' || !*(p + 1)) {
g_string_append_c(out, *p);
continue;
}
p++; p++;
if (*p == '\\') { if (*p == '\\') {
g_string_append_c(out, '\\'); g_string_append_c(out, '\\');
@@ -566,9 +552,6 @@ ff_unescape_sender_resource(const char* res)
g_string_append_c(out, '\\'); g_string_append_c(out, '\\');
g_string_append_c(out, *p); g_string_append_c(out, *p);
} }
} else {
g_string_append_c(out, *p);
}
} }
return g_string_free(out, FALSE); return g_string_free(out, FALSE);
} }
@@ -598,6 +581,33 @@ ff_parsed_line_free(ff_parsed_line_t* pl)
g_free(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. // Parse a single line. Returns NULL on parse failure.
// Line format: {timestamp} [{metadata}] {sender}: {message} // Line format: {timestamp} [{metadata}] {sender}: {message}
ff_parsed_line_t* ff_parsed_line_t*
@@ -652,35 +662,19 @@ ff_parse_line(const char* line)
// Parse metadata section [...] // Parse metadata section [...]
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']'); const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
if (bracket_end) { if (!bracket_end) {
char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1); // No closing bracket — malformed metadata
ff_parsed_line_free(result);
g_free(work);
return NULL;
}
// Split by unescaped '|' auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
char** parts = ff_split_meta(meta_content); char** parts = ff_split_meta(meta_content);
if (parts) { if (parts) {
int i = 0; _ff_parse_meta_parts(parts, result);
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_strfreev(parts);
} }
g_free(meta_content);
// Parse sender: message after '] ' // Parse sender: message after '] '
const char* after_meta = bracket_end + 1; const char* after_meta = bracket_end + 1;
@@ -690,7 +684,7 @@ ff_parse_line(const char* line)
// Find first *unescaped* ': ' which separates sender from message. // Find first *unescaped* ': ' which separates sender from message.
const char* colon = ff_find_unescaped_colonspace(after_meta); const char* colon = ff_find_unescaped_colonspace(after_meta);
if (colon) { if (colon) {
char* raw_sender = g_strndup(after_meta, colon - after_meta); auto_gchar gchar* raw_sender = g_strndup(after_meta, colon - after_meta);
// Split sender into jid/resource, then unescape resource // Split sender into jid/resource, then unescape resource
char* slash = strchr(raw_sender, '/'); char* slash = strchr(raw_sender, '/');
@@ -700,7 +694,6 @@ ff_parse_line(const char* line)
} else { } else {
result->from_jid = g_strdup(raw_sender); result->from_jid = g_strdup(raw_sender);
} }
g_free(raw_sender);
result->message = ff_unescape_message(colon + 2); result->message = ff_unescape_message(colon + 2);
} else { } else {
@@ -708,12 +701,6 @@ ff_parse_line(const char* line)
result->from_jid = g_strdup("unknown"); result->from_jid = g_strdup("unknown");
result->message = ff_unescape_message(after_meta); result->message = ff_unescape_message(after_meta);
} }
} else {
// No closing bracket — malformed metadata
ff_parsed_line_free(result);
g_free(work);
return NULL;
}
} else if (first_space) { } else if (first_space) {
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg} // Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
result->timestamp_str = g_strndup(work, first_space - work); result->timestamp_str = g_strndup(work, first_space - work);

View File

@@ -8,8 +8,26 @@
* vim: expandtab:ts=4:sts=4:sw=4 * vim: expandtab:ts=4:sts=4:sw=4
* *
* Flat-file backend: integrity verification (/history verify). * 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" #include "config.h"
@@ -25,22 +43,32 @@
#include "config/files.h" #include "config/files.h"
#include "database_flatfile.h" #include "database_flatfile.h"
GSList* // =========================================================================
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
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)); // Contact discovery
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;
}
// 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; GSList* contact_dirs = NULL;
if (contact_barejid) { if (contact_barejid) {
@@ -48,22 +76,21 @@ ff_verify_integrity(const gchar* const contact_barejid)
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) { if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir)); contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
} else { } else {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_INFO; _issue_new(INTEGRITY_INFO, contact_barejid, 0,
issue->file = g_strdup(contact_barejid); g_strdup("No log files found for this contact")));
issue->line = 0;
issue->message = g_strdup("No log files found for this contact");
issues = g_slist_prepend(issues, issue);
return issues;
} }
} else { return contact_dirs;
// Discover all contact directories }
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG); auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid); auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir); auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
GDir* dir = g_dir_open(base_dir, 0, NULL); GDir* dir = g_dir_open(base_dir, 0, NULL);
if (dir) { if (!dir)
return NULL;
const gchar* dname; const gchar* dname;
while ((dname = g_dir_read_name(dir)) != NULL) { while ((dname = g_dir_read_name(dir)) != NULL) {
char* full = g_strdup_printf("%s/%s", base_dir, dname); char* full = g_strdup_printf("%s/%s", base_dir, dname);
@@ -74,51 +101,56 @@ ff_verify_integrity(const gchar* const contact_barejid)
} }
} }
g_dir_close(dir); g_dir_close(dir);
} return contact_dirs;
} }
// Verify each contact directory (single history.log per contact) // =========================================================================
for (GSList* cd = contact_dirs; cd; cd = cd->next) { // Permission check
const char* cdir_path = cd->data; // =========================================================================
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path); static void
if (!g_file_test(filepath, G_FILE_TEST_EXISTS)) _check_permissions(const char* filepath, const char* basename, GSList** issues)
continue; {
const char* basename = "history.log";
// Check file permissions
struct stat st; struct stat st;
if (g_stat(filepath, &st) == 0) { if (g_stat(filepath, &st) != 0)
if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) { return;
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
issue->level = INTEGRITY_WARNING; return;
issue->file = g_strdup(basename); *issues = g_slist_prepend(*issues,
issue->line = 0; _issue_new(INTEGRITY_WARNING, basename, 0,
issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777); g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
issues = g_slist_prepend(issues, issue); 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
}
FILE* fp = fopen(filepath, "r"); // =========================================================================
if (!fp) // First pass: per-line content checks + id collection
continue; // =========================================================================
//
// 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).
// BOM check 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)) { if (ff_skip_bom(fp)) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_INFO; _issue_new(INTEGRITY_INFO, basename, 0,
issue->file = g_strdup(basename); g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
issue->line = 0;
issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
issues = g_slist_prepend(issues, issue);
} }
char* buf = NULL; char* buf = NULL;
int lineno = 0; int lineno = 0;
GDateTime* prev_ts = NULL; 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 has_crlf = FALSE;
gboolean is_empty = TRUE; gboolean is_empty = TRUE;
@@ -139,12 +171,9 @@ ff_verify_integrity(const gchar* const contact_barejid)
const gchar* end; const gchar* end;
if (!g_utf8_validate(buf, -1, &end)) { if (!g_utf8_validate(buf, -1, &end)) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_ERROR; _issue_new(INTEGRITY_ERROR, basename, lineno,
issue->file = g_strdup(basename); g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
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); free(buf);
continue; continue;
} }
@@ -152,105 +181,88 @@ ff_verify_integrity(const gchar* const contact_barejid)
for (gsize i = 0; i < len; i++) { for (gsize i = 0; i < len; i++) {
unsigned char ch = (unsigned char)buf[i]; unsigned char ch = (unsigned char)buf[i];
if (ch < 0x20 && ch != '\t') { if (ch < 0x20 && ch != '\t') {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_WARNING; _issue_new(INTEGRITY_WARNING, basename, lineno,
issue->file = g_strdup(basename); g_strdup_printf("Contains control character 0x%02x", ch)));
issue->line = lineno;
issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
issues = g_slist_prepend(issues, issue);
break; break;
} }
} }
ff_parsed_line_t* pl = ff_parse_line(buf); ff_parsed_line_t* pl = ff_parse_line(buf);
if (!pl) { if (!pl) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_ERROR; _issue_new(INTEGRITY_ERROR, basename, lineno,
issue->file = g_strdup(basename); g_strdup("Unparsable line")));
issue->line = lineno;
issue->message = g_strdup("Unparsable line");
issues = g_slist_prepend(issues, issue);
free(buf); free(buf);
continue; continue;
} }
free(buf); free(buf);
// Timestamp ordering
if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) { 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_cur = g_date_time_format_iso8601(pl->timestamp);
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts); auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_WARNING; _issue_new(INTEGRITY_WARNING, basename, lineno,
issue->file = g_strdup(basename); g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
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) if (prev_ts)
g_date_time_unref(prev_ts); g_date_time_unref(prev_ts);
prev_ts = g_date_time_ref(pl->timestamp); prev_ts = g_date_time_ref(pl->timestamp);
// Duplicate stanza-id / archive-id if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (pl->stanza_id && strlen(pl->stanza_id) > 0) { if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
if (g_hash_table_contains(seen_ids, pl->stanza_id)) { *issues = g_slist_prepend(*issues,
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); _issue_new(INTEGRITY_WARNING, basename, lineno,
issue->level = INTEGRITY_WARNING; g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
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 { } else {
g_hash_table_insert(seen_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno)); 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)); 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 (pl->archive_id && pl->archive_id[0] != '\0') {
if (g_hash_table_contains(seen_ids, pl->archive_id)) { if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_WARNING; _issue_new(INTEGRITY_WARNING, basename, lineno,
issue->file = g_strdup(basename); g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
issue->line = lineno;
issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
issues = g_slist_prepend(issues, issue);
} else { } else {
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno)); g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
} }
} }
ff_parsed_line_free(pl); ff_parsed_line_free(pl);
} }
fclose(fp); if (prev_ts)
g_date_time_unref(prev_ts);
if (has_crlf) { if (has_crlf) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_WARNING; _issue_new(INTEGRITY_WARNING, basename, 0,
issue->file = g_strdup(basename); g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
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) { if (is_empty) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_INFO; _issue_new(INTEGRITY_INFO, basename, 0,
issue->file = g_strdup(basename); g_strdup("File is empty (no message lines)")));
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"); // Second pass: LMC reference resolution
if (fp) { // =========================================================================
int b1 = fgetc(fp); //
int b2 = fgetc(fp); // Walks the file again, and for every line that carries a `corrects:` link,
int b3 = fgetc(fp); // checks that the referenced stanza-id was seen in pass 1. Broken references
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF)) // surface as INTEGRITY_ERROR.
fseek(fp, 0, SEEK_SET);
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;
lineno = 0;
while ((buf = ff_readline(fp, NULL)) != NULL) { while ((buf = ff_readline(fp, NULL)) != NULL) {
lineno++; 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); gsize len = strlen(buf);
@@ -266,25 +278,81 @@ ff_verify_integrity(const gchar* const contact_barejid)
if (!pl) if (!pl)
continue; continue;
if (pl->replace_id && strlen(pl->replace_id) > 0) { if (pl->replace_id && pl->replace_id[0] != '\0'
if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) { && !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t)); *issues = g_slist_prepend(*issues,
issue->level = INTEGRITY_ERROR; _issue_new(INTEGRITY_ERROR, basename, lineno,
issue->file = g_strdup(basename); g_strdup_printf("Broken correction reference: corrects:%s not found",
issue->line = lineno; pl->replace_id)));
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); 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); fclose(fp);
} }
if (prev_ts) g_hash_table_destroy(seen_stanza_ids);
g_date_time_unref(prev_ts); g_hash_table_destroy(seen_archive_ids);
g_hash_table_destroy(seen_ids);
g_hash_table_destroy(all_stanza_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); 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); auto_char char* filename = _get_db_filename(account);
if (!filename) { 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(); sqlite3_shutdown();
return FALSE; return FALSE;
} }
@@ -147,6 +149,28 @@ _sqlite_init(ProfAccount* account)
return TRUE; 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` (" const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, " "`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
"`from_jid` TEXT NOT NULL, " "`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_service_chars),
PROF_FUNC_TEST(message_db_history_verify), PROF_FUNC_TEST(message_db_history_verify),
PROF_FUNC_TEST(message_db_history_lmc), PROF_FUNC_TEST(message_db_history_lmc),
PROF_FUNC_TEST(message_db_history_multi_resource),
/* Basic message send/receive */ /* Basic message send/receive */
PROF_FUNC_TEST(message_send), 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")); assert_false(prof_output_exact("lmc-before-correction-aaa"));
prof_timeout_reset(); 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_service_chars(void** state);
void message_db_history_verify(void** state); void message_db_history_verify(void** state);
void message_db_history_lmc(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); 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 * 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_legacy_format(void** state);
void test_ff_parse_line_no_metadata(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_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 */ /* type/enc conversion round-trip */
void test_ff_type_str_roundtrip(void** state); 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; 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) { while (1) {
char* buf = ff_readline(fp, NULL); auto_char char* buf = ff_readline(fp, NULL);
if (!buf) 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; break;
if (buf[0] == '#' || buf[0] == '\0') { // ff_parse_line already rejects empty/comment lines, no need to pre-filter.
free(buf);
continue;
}
ff_parsed_line_t* pl = ff_parse_line(buf); ff_parsed_line_t* pl = ff_parse_line(buf);
free(buf);
if (pl) { if (pl) {
total_parsed++; total_parsed++;
if (pl->replace_id && strlen(pl->replace_id) > 0) 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_legacy_format),
cmocka_unit_test(test_ff_parse_line_no_metadata), 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_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 // Type/enc round-trip
cmocka_unit_test(test_ff_type_str_roundtrip), cmocka_unit_test(test_ff_type_str_roundtrip),