// SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of CProof. // See LICENSE for the full GPLv3 text and the special OpenSSL linking exception. /* * database_flatfile_verify.c * vim: expandtab:ts=4:sts=4:sw=4 * * Flat-file backend: integrity verification (/history verify). * * 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" #include #include #include #include #include #include #include "log.h" #include "config/files.h" #include "database_flatfile.h" // ========================================================================= // Issue construction // ========================================================================= static integrity_issue_t* _issue_new(integrity_level_t level, const char* file, int line, char* message) { 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; } // ========================================================================= // Contact discovery // ========================================================================= // 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) { auto_gchar gchar* cdir = ff_get_contact_dir(contact_barejid); if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) { contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir)); } else { *issues = g_slist_prepend(*issues, _issue_new(INTEGRITY_INFO, contact_barejid, 0, g_strdup("No log files found for this contact"))); } return contact_dirs; } 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) return NULL; 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; } // ========================================================================= // 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'; } if (len == 0 || buf[0] == '#') { free(buf); continue; } is_empty = FALSE; 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; } 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; } } 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); 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)) { // Older clients (Pidgin, Adium, even early Profanity) // sometimes reuse client-generated stanza-ids across // distinct messages, so a collision is not a real // integrity defect — log it for diagnostics but don't // surface it through /history verify. log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d", pl->stanza_id, basename, lineno); } 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; } // Reconstruct the contact JID from the directory name so issue reports // identify which contact the problem belongs to (every contact has its // own history.log, so the bare filename is ambiguous). auto_gchar gchar* dir_name = g_path_get_basename(cdir_path); auto_gcharv gchar** parts = g_strsplit(dir_name, "_at_", -1); auto_gchar gchar* contact_jid = g_strjoinv("@", parts); auto_gchar gchar* basename = g_strdup_printf("%s/history.log", contact_jid); _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); return g_slist_reverse(issues); }