no-DB mode implementation #94
@@ -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);
|
||||
|
||||
|
||||
@@ -148,6 +148,17 @@ ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
|
||||
|
||||
// --- Integrity verification (database_flatfile_verify.c) ---
|
||||
|
||||
// Run integrity checks against one contact's history.log (or every contact
|
||||
// when contact_barejid == NULL).
|
||||
//
|
||||
// Returns a freshly allocated GSList<integrity_issue_t*> reporting:
|
||||
// - File-level: missing log, BOM, CRLF, empty, wrong permissions
|
||||
// - Line-level: invalid UTF-8, control characters, unparsable lines,
|
||||
// timestamps out of order, duplicate stanza-id /
|
||||
// archive-id (tracked separately)
|
||||
// - Cross-line: broken `corrects:` LMC references
|
||||
//
|
||||
// Callers must free with g_slist_free_full(issues, integrity_issue_free).
|
||||
GSList* ff_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -98,7 +98,7 @@ ff_get_message_enc_type(const char* const encstr)
|
||||
|
||||
// Sanitise a JID for use as a directory name.
|
||||
// 1. Replace '@' with '_at_'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\\', '..'
|
||||
// This prevents a malicious federated JID like "../../../tmp/pwned" from
|
||||
// escaping the log directory tree.
|
||||
char*
|
||||
@@ -107,36 +107,32 @@ ff_jid_to_dir(const char* jid)
|
||||
if (!jid || jid[0] == '\0')
|
||||
return NULL;
|
||||
|
||||
// Replace '@' first
|
||||
char* step1 = str_replace(jid, "@", "_at_");
|
||||
if (!step1)
|
||||
// Replace '@' with '_at_' (str_replace returns a freshly allocated string)
|
||||
char* result = str_replace(jid, "@", "_at_");
|
||||
if (!result)
|
||||
return NULL;
|
||||
|
||||
// Replace '/' and '\\' with '_' to prevent path traversal
|
||||
GString* out = g_string_sized_new(strlen(step1));
|
||||
for (const char* p = step1; *p; p++) {
|
||||
if (*p == '/' || *p == '\\') {
|
||||
g_string_append_c(out, '_');
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
// Replace path-separator characters in place
|
||||
for (char* p = result; *p; p++) {
|
||||
if (*p == '/' || *p == '\\')
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
|
||||
*p = '_';
|
||||
}
|
||||
free(step1);
|
||||
|
||||
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
|
||||
char* result = g_string_free(out, FALSE);
|
||||
char* dotdot;
|
||||
while ((dotdot = strstr(result, "..")) != NULL) {
|
||||
dotdot[0] = '_';
|
||||
dotdot[1] = '_';
|
||||
}
|
||||
|
||||
// Reject empty result
|
||||
if (result[0] == '\0') {
|
||||
g_free(result);
|
||||
free(result);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
// Callers free via g_free, str_replace allocates via malloc — handover.
|
||||
char* gresult = g_strdup(result);
|
||||
free(result);
|
||||
return gresult;
|
||||
}
|
||||
|
||||
// Get the base directory for a contact's logs:
|
||||
@@ -172,15 +168,12 @@ ff_get_log_path(const char* contact_barejid)
|
||||
gboolean
|
||||
ff_ensure_dir(const char* path)
|
||||
{
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
|
||||
// Verify it's not a symlink
|
||||
struct stat st;
|
||||
if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
if (g_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR))
|
||||
return TRUE;
|
||||
if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
|
||||
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
|
||||
return FALSE;
|
||||
@@ -231,25 +224,25 @@ ff_unescape_message(const char* text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
@@ -296,31 +289,31 @@ ff_unescape_meta_value(const char* val)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
early exit here would lower the nesting and potentially improve readability early exit here would lower the nesting and potentially improve readability
something like:
if(!condition) {
g_string_append_c(out, *p);
continue;
}
jabber.developer2
commented
Corrected Corrected
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
@@ -376,11 +369,8 @@ ff_readline(FILE* fp, gboolean* truncated)
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
// Return empty string so caller's loop continues (parse will reject it).
|
||||
// Use malloc() so callers can always use free() consistently.
|
||||
char* empty = malloc(1);
|
||||
if (empty)
|
||||
empty[0] = '\0';
|
||||
return empty;
|
||||
// strdup() so callers can always free() consistently.
|
||||
return strdup("");
|
||||
}
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
nit: Why not nit: Why not `strdup("")`?
jabber.developer2
commented
Corrected Corrected
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
@@ -465,21 +455,14 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
|
||||
}
|
||||
|
||||
// Escape message body to prevent log injection
|
||||
char* safe_msg = ff_escape_message(message_text);
|
||||
auto_char char* safe_msg = ff_escape_message(message_text);
|
||||
|
||||
// Build complete line and write with a single fwrite()
|
||||
GString* full_line = g_string_new(NULL);
|
||||
g_string_printf(full_line, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
|
||||
size_t to_write = full_line->len;
|
||||
size_t written = fwrite(full_line->str, 1, to_write, fp);
|
||||
if (written != to_write) {
|
||||
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
|
||||
int ret = fprintf(fp, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
if (ret < 0) {
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
why not auto_char? why not auto_char?
jabber.developer2
commented
Corrected Corrected
|
||||
log_error("flatfile: fprintf failed (errno=%d)", errno);
|
||||
}
|
||||
|
||||
g_string_free(full_line, TRUE);
|
||||
g_free(safe_msg);
|
||||
g_string_free(meta, TRUE);
|
||||
g_string_free(sender, TRUE);
|
||||
}
|
||||
@@ -554,19 +537,19 @@ ff_unescape_sender_resource(const char* res)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(res));
|
||||
for (const char* p = res; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
@@ -598,6 +581,33 @@ ff_parsed_line_free(ff_parsed_line_t* pl)
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// Populate the parsed-line metadata fields from the pipe-split parts[]
|
||||
// (the contents of a "[...]" section). Index 0 is type, index 1 is enc;
|
||||
// remaining parts are key:value pairs (id:, aid:, corrects:, to:, to_res:, read:).
|
||||
static void
|
||||
_ff_parse_meta_parts(char** parts, ff_parsed_line_t* out)
|
||||
{
|
||||
for (int i = 0; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
out->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
out->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
|
||||
out->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
|
||||
out->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
out->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
out->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
out->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
out->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a single line. Returns NULL on parse failure.
|
||||
// Line format: {timestamp} [{metadata}] {sender}: {message}
|
||||
ff_parsed_line_t*
|
||||
@@ -652,68 +662,45 @@ ff_parse_line(const char* line)
|
||||
|
||||
// Parse metadata section [...]
|
||||
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
|
||||
if (bracket_end) {
|
||||
char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
|
||||
// Split by unescaped '|'
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
int i = 0;
|
||||
for (; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
result->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
result->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
|
||||
result->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
|
||||
result->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
result->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
result->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
result->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
result->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_free(meta_content);
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
char* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
g_free(raw_sender);
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else {
|
||||
if (!bracket_end) {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
_ff_parse_meta_parts(parts, result);
|
||||
g_strfreev(parts);
|
||||
}
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
auto_gchar gchar* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else if (first_space) {
|
||||
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
|
||||
@@ -8,8 +8,26 @@
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Flat-file backend: integrity verification (/history verify).
|
||||
* Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
|
||||
* references, file permissions, BOM, CRLF, UTF-8, control chars.
|
||||
*
|
||||
* High-level flow (see ff_verify_integrity):
|
||||
* 1. Resolve target contact directories — either a single contact (when
|
||||
* contact_barejid != NULL) or every contact directory under
|
||||
* flatlog/<account>/.
|
||||
* 2. For each contact's history.log:
|
||||
* - Check file permissions (warn if not 0600).
|
||||
* - First pass: line-by-line UTF-8 / control-char / parser /
|
||||
* duplicate-id / timestamp-ordering checks. Records every
|
||||
* stanza-id and archive-id seen so the second pass can resolve
|
||||
* LMC references. stanza-id and archive-id are tracked in
|
||||
* *separate* hash tables — duplicate detection is reported
|
||||
* per id-kind to avoid spurious cross-kind warnings.
|
||||
* - Second pass: re-read the file and verify each `corrects:` link
|
||||
* points at a stanza-id that was seen in pass 1.
|
||||
* 3. Aggregate every issue into a GSList<integrity_issue_t> and return
|
||||
* it to the caller, ready for /history verify rendering.
|
||||
*
|
||||
* The file is split into small helpers so each pass can be understood in
|
||||
* isolation; ff_verify_integrity itself is just orchestration.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
@@ -25,22 +43,32 @@
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
// =========================================================================
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
Here and everywhere else, we can use new style for consistency with Here and everywhere else, we can use new style for consistency with `g_new0`
jabber.developer2
commented
Corrected Corrected
|
||||
// Issue construction
|
||||
// =========================================================================
|
||||
|
||||
static integrity_issue_t*
|
||||
_issue_new(integrity_level_t level, const char* file, int line, char* message)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
integrity_issue_t* issue = g_new0(integrity_issue_t, 1);
|
||||
issue->level = level;
|
||||
issue->file = g_strdup(file ? file : "");
|
||||
issue->line = line;
|
||||
issue->message = message; // takes ownership of the (g_strdup'd) message
|
||||
return issue;
|
||||
}
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Flat-file backend not initialized");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
// =========================================================================
|
||||
// Contact discovery
|
||||
// =========================================================================
|
||||
|
||||
// If contact specified, verify just that contact; otherwise discover all contacts
|
||||
// Build the list of contact directories to verify. If contact_barejid is
|
||||
// supplied, the list is either a singleton or empty (and an INFO issue is
|
||||
// appended explaining no logs exist for that contact). Otherwise, every
|
||||
// subdirectory of flatlog/<account>/ is enumerated.
|
||||
static GSList*
|
||||
_collect_contact_dirs(const gchar* const contact_barejid, GSList** issues)
|
||||
{
|
||||
GSList* contact_dirs = NULL;
|
||||
|
||||
if (contact_barejid) {
|
||||
@@ -48,243 +76,283 @@ ff_verify_integrity(const gchar* const contact_barejid)
|
||||
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
|
||||
} else {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(contact_barejid);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("No log files found for this contact");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
} else {
|
||||
// Discover all contact directories
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
GDir* dir = g_dir_open(base_dir, 0, NULL);
|
||||
if (dir) {
|
||||
const gchar* dname;
|
||||
while ((dname = g_dir_read_name(dir)) != NULL) {
|
||||
char* full = g_strdup_printf("%s/%s", base_dir, dname);
|
||||
if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, full);
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
|
||||
g_strdup("No log files found for this contact")));
|
||||
}
|
||||
return contact_dirs;
|
||||
}
|
||||
|
||||
// Verify each contact directory (single history.log per contact)
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
const char* cdir_path = cd->data;
|
||||
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
|
||||
auto_gchar gchar* my_dir = ff_jid_to_dir(g_flatfile_account_jid);
|
||||
auto_gchar gchar* base_dir = g_strdup_printf("%s/%s", data_path, my_dir);
|
||||
|
||||
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
|
||||
if (!g_file_test(filepath, G_FILE_TEST_EXISTS))
|
||||
continue;
|
||||
GDir* dir = g_dir_open(base_dir, 0, NULL);
|
||||
if (!dir)
|
||||
return NULL;
|
||||
|
||||
const char* basename = "history.log";
|
||||
const gchar* dname;
|
||||
while ((dname = g_dir_read_name(dir)) != NULL) {
|
||||
char* full = g_strdup_printf("%s/%s", base_dir, dname);
|
||||
if (g_file_test(full, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_prepend(contact_dirs, full);
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
I am not sure if it's ok to just silently I am not sure if it's ok to just silently `continue` in this place. It deserves at the very least debug log.
jabber.developer2
commented
Corrected Corrected
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
return contact_dirs;
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) == 0) {
|
||||
if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup_printf("File permissions are %o, expected 600 (sensitive data)", st.st_mode & 0777);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
// =========================================================================
|
||||
// Permission check
|
||||
// =========================================================================
|
||||
|
||||
static void
|
||||
_check_permissions(const char* filepath, const char* basename, GSList** issues)
|
||||
{
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) != 0)
|
||||
return;
|
||||
if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
|
||||
return;
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
|
||||
st.st_mode & 0777)));
|
||||
}
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in `database_flatfile.c:163`
jabber.developer2
commented
Corrected Corrected
|
||||
|
||||
// =========================================================================
|
||||
// First pass: per-line content checks + id collection
|
||||
// =========================================================================
|
||||
//
|
||||
// For every non-empty, non-comment line:
|
||||
// - validate UTF-8
|
||||
// - flag control characters
|
||||
// - try to parse via ff_parse_line(); if that fails, record the lineno
|
||||
// - check timestamp monotonicity
|
||||
// - flag duplicate stanza-id and duplicate archive-id (separate tables)
|
||||
// - record every stanza-id encountered for LMC pass 2
|
||||
//
|
||||
// Also detects: BOM (informational), CRLF line endings (warning), empty
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
I am not sure if the overlap between 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.
jabber.developer2
commented
Postponed, architecture only question Postponed, architecture only question
|
||||
// file (informational).
|
||||
|
||||
static void
|
||||
_first_pass(FILE* fp, const char* basename,
|
||||
GHashTable* seen_stanza_ids, GHashTable* seen_archive_ids,
|
||||
GHashTable* all_stanza_ids, GSList** issues)
|
||||
{
|
||||
if (ff_skip_bom(fp)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
gboolean has_crlf = FALSE;
|
||||
gboolean is_empty = TRUE;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
|
||||
if (len > 0 && buf[len - 1] == '\r') {
|
||||
has_crlf = TRUE;
|
||||
buf[--len] = '\0';
|
||||
}
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp)
|
||||
continue;
|
||||
|
||||
// BOM check
|
||||
if (ff_skip_bom(fp)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File has UTF-8 BOM — harmless but unnecessary");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
GHashTable* seen_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
gboolean has_crlf = FALSE;
|
||||
gboolean is_empty = TRUE;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
|
||||
if (len > 0 && buf[len - 1] == '\r') {
|
||||
has_crlf = TRUE;
|
||||
buf[--len] = '\0';
|
||||
}
|
||||
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
is_empty = FALSE;
|
||||
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(buf, -1, &end)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf));
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Contains control character 0x%02x", ch);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup("Unparsable line");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
is_empty = FALSE;
|
||||
|
||||
// Timestamp ordering
|
||||
if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
|
||||
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
|
||||
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
prev_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
// Duplicate stanza-id / archive-id
|
||||
if (pl->stanza_id && strlen(pl->stanza_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->stanza_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
if (pl->archive_id && strlen(pl->archive_id) > 0) {
|
||||
if (g_hash_table_contains(seen_ids, pl->archive_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(buf, -1, &end)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
if (has_crlf) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File uses Windows line endings (CRLF) — consider converting to LF");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
|
||||
if (is_empty) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_INFO;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("File is empty (no message lines)");
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
|
||||
// Second pass: check LMC references
|
||||
fp = fopen(filepath, "r");
|
||||
if (fp) {
|
||||
int b1 = fgetc(fp);
|
||||
int b2 = fgetc(fp);
|
||||
int b3 = fgetc(fp);
|
||||
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF))
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
lineno = 0;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\r')
|
||||
buf[--len] = '\0';
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
if (pl->replace_id && strlen(pl->replace_id) > 0) {
|
||||
if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup(basename);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
|
||||
issues = g_slist_prepend(issues, issue);
|
||||
}
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Contains control character 0x%02x", ch)));
|
||||
break;
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup("Unparsable line")));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
free(buf);
|
||||
|
||||
if (prev_ts && g_date_time_compare(pl->timestamp, prev_ts) < 0) {
|
||||
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
|
||||
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
g_hash_table_destroy(seen_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
prev_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
g_hash_table_insert(all_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
if (pl->archive_id && pl->archive_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
|
||||
if (has_crlf) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
|
||||
}
|
||||
if (is_empty) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File is empty (no message lines)")));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Second pass: LMC reference resolution
|
||||
// =========================================================================
|
||||
//
|
||||
// Walks the file again, and for every line that carries a `corrects:` link,
|
||||
// checks that the referenced stanza-id was seen in pass 1. Broken references
|
||||
// surface as INTEGRITY_ERROR.
|
||||
|
||||
static void
|
||||
_lmc_pass(FILE* fp, const char* basename, GHashTable* all_stanza_ids, GSList** issues)
|
||||
{
|
||||
ff_skip_bom(fp);
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
Please, extract bom-check to a different method. Please, extract bom-check to a different method.
jabber.developer2
commented
Corrected Corrected
|
||||
gsize len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\r')
|
||||
buf[--len] = '\0';
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
if (pl->replace_id && pl->replace_id[0] != '\0'
|
||||
&& !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Broken correction reference: corrects:%s not found",
|
||||
pl->replace_id)));
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Per-contact verification
|
||||
// =========================================================================
|
||||
|
||||
static void
|
||||
_verify_contact_dir(const char* cdir_path, GSList** issues)
|
||||
{
|
||||
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
|
||||
if (!g_file_test(filepath, G_FILE_TEST_EXISTS)) {
|
||||
log_debug("flatfile verify: skipping %s (no history.log)", cdir_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const char* basename = "history.log";
|
||||
|
||||
_check_permissions(filepath, basename, issues);
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp) {
|
||||
log_warning("flatfile verify: cannot open %s for reading", filepath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate tables by id-kind: a stanza-id and an archive-id can legitimately
|
||||
// share the same string without that being a duplicate.
|
||||
GHashTable* seen_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* seen_archive_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable* all_stanza_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
|
||||
_first_pass(fp, basename, seen_stanza_ids, seen_archive_ids, all_stanza_ids, issues);
|
||||
fclose(fp);
|
||||
|
||||
// Second pass: LMC references
|
||||
fp = fopen(filepath, "r");
|
||||
if (fp) {
|
||||
_lmc_pass(fp, basename, all_stanza_ids, issues);
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
g_hash_table_destroy(seen_stanza_ids);
|
||||
g_hash_table_destroy(seen_archive_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Public entry point
|
||||
// =========================================================================
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
issues = g_slist_prepend(issues,
|
||||
_issue_new(INTEGRITY_ERROR, "N/A", 0,
|
||||
g_strdup("Flat-file backend not initialized")));
|
||||
return issues;
|
||||
}
|
||||
|
||||
GSList* contact_dirs = _collect_contact_dirs(contact_barejid, &issues);
|
||||
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
_verify_contact_dir((const char*)cd->data, &issues);
|
||||
}
|
||||
|
||||
g_slist_free_full(contact_dirs, g_free);
|
||||
|
||||
@@ -128,6 +128,8 @@ _sqlite_init(ProfAccount* account)
|
||||
|
||||
auto_char char* filename = _get_db_filename(account);
|
||||
if (!filename) {
|
||||
log_error("Error initializing SQLite database: could not derive chatlog.db path for account '%s'.",
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
We should add error logging here. We should add error logging here.
jabber.developer2
commented
Corrected Corrected
|
||||
account && account->jid ? account->jid : "(unknown)");
|
||||
sqlite3_shutdown();
|
||||
return FALSE;
|
||||
}
|
||||
@@ -147,6 +149,28 @@ _sqlite_init(ProfAccount* account)
|
||||
return TRUE;
|
||||
}
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
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?
jabber.developer2
commented
Corrected Corrected
|
||||
|
||||
// ChatLogs schema overview:
|
||||
// id AUTOINCREMENT primary key. Used internally as the
|
||||
// anchor for LMC (Last Message Correction) chains.
|
||||
// from_jid/to_jid Bare JIDs (NOT NULL). Used for window resolution.
|
||||
// from_resource Resource of the sender (NULLable for legacy rows).
|
||||
// to_resource Resource of the recipient (NULLable).
|
||||
// message Plaintext after decryption / format normalisation.
|
||||
// timestamp ISO-8601 (UTC). Indexed for range queries.
|
||||
// type "chat" | "muc" | "mucpm" (mirrors prof_msg_type_t).
|
||||
// stanza_id XEP-0359 unique-and-stable id, used for dedup +
|
||||
// LMC sender lookup.
|
||||
// archive_id MAM (XEP-0313) archive id; dedupes rebroadcast.
|
||||
// encryption "none" | "omemo" | "otr" | "pgp" | "ox"
|
||||
// (mirrors prof_enc_t).
|
||||
// marked_read 0/1 — for unread-message badge bookkeeping.
|
||||
// replace_id XEP-0308 message-correction stanza-id of the
|
||||
// original message this row replaces.
|
||||
// replaces_db_id Local FK back-link: id of the row this row
|
||||
// corrects. Set on insert when replace_id resolves.
|
||||
// replaced_by_db_id Inverse FK: id of the most recent correction.
|
||||
// Maintained by the AFTER INSERT trigger below so
|
||||
// readers can follow the chain forward in O(1).
|
||||
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -472,3 +472,67 @@ message_db_history_lmc(void** state)
|
||||
assert_false(prof_output_exact("lmc-before-correction-aaa"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
/*
|
||||
* Test: messages from the SAME bare JID but with DIFFERENT resources are
|
||||
* stored together in one contact's history AND remain distinguishable on
|
||||
* reopen — the resource part of the sender JID must be preserved per row,
|
||||
* not collapsed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. /history on
|
||||
* 2. buddy1@localhost/phone sends msg A
|
||||
* 3. buddy1@localhost/laptop sends msg B
|
||||
* 4. buddy1@localhost/tablet sends msg C
|
||||
* 5. Close the auto-opened chat window so its in-memory buffer is gone.
|
||||
* 6. /msg buddy1@localhost — chatwin_new() rehydrates from DB.
|
||||
* 7. All three bodies must reappear, AND the resource markers
|
||||
* "/phone", "/laptop", "/tablet" must each be visible on at least one
|
||||
* history line (PREF_RESOURCE_MESSAGE is on by default in the test
|
||||
* profile, so the renderer prefixes each line with "Buddy1/<res>").
|
||||
*/
|
||||
void
|
||||
message_db_history_multi_resource(void** state)
|
||||
{
|
||||
prof_connect();
|
||||
|
||||
prof_input("/history on");
|
||||
assert_true(prof_output_regex("Chat history enabled\\."));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='res-multi-A' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/phone' type='chat'>"
|
||||
"<body>multi-resource-A-from-phone</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='res-multi-B' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/laptop' type='chat'>"
|
||||
"<body>multi-resource-B-from-laptop</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/laptop (win 2)"));
|
||||
|
||||
stbbr_send(
|
||||
"<message id='res-multi-C' to='stabber@localhost' "
|
||||
"from='buddy1@localhost/tablet' type='chat'>"
|
||||
"<body>multi-resource-C-from-tablet</body>"
|
||||
"</message>");
|
||||
assert_true(prof_output_exact("<< chat message: Buddy1/tablet (win 2)"));
|
||||
|
||||
prof_input("/close 2");
|
||||
assert_true(prof_output_exact("Closed window 2"));
|
||||
|
||||
prof_input("/msg buddy1@localhost");
|
||||
|
||||
/* All three bodies must be present in history (single-contact aggregation) */
|
||||
assert_true(prof_output_exact("multi-resource-A-from-phone"));
|
||||
assert_true(prof_output_exact("multi-resource-B-from-laptop"));
|
||||
assert_true(prof_output_exact("multi-resource-C-from-tablet"));
|
||||
|
||||
/* Each resource must remain identifiable on rehydrate — proves we don't
|
||||
* lose per-row resource info when loading the same JID's chat back. */
|
||||
assert_true(prof_output_regex("Buddy1/phone"));
|
||||
assert_true(prof_output_regex("Buddy1/laptop"));
|
||||
assert_true(prof_output_regex("Buddy1/tablet"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
* ================================================================ */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1106,15 +1106,11 @@ test_stress_lmc_many_corrections(void** state)
|
||||
int corrections_found = 0;
|
||||
|
||||
|
jabber.developer marked this conversation as resolved
jabber.developer
commented
Maybe Maybe `auto_char`, if `common.h` import is allowed?
jabber.developer2
commented
Corrected Corrected
|
||||
while (1) {
|
||||
char* buf = ff_readline(fp, NULL);
|
||||
auto_char char* buf = ff_readline(fp, NULL);
|
||||
if (!buf)
|
||||
|
jabber.developer
commented
Here and everywhere else: no need for this check, as it will already be done within the Here and everywhere else: no need for this check, as it will already be done within the `ff_parse_line`.
jabber.developer2
commented
Corrected Corrected
|
||||
break;
|
||||
if (buf[0] == '#' || buf[0] == '\0') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
// ff_parse_line already rejects empty/comment lines, no need to pre-filter.
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) {
|
||||
total_parsed++;
|
||||
if (pl->replace_id && strlen(pl->replace_id) > 0)
|
||||
|
||||
@@ -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),
|
||||
|
||||
I don't understand the need for
gstringandstep1. I propose a simpler solution that might be more efficient, but I might've missed some intricacies:Corrected