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);
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|||||||
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
jabber.developer
commented
nit: Why not nit: Why not `strdup("")`?
jabber.developer2
commented
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
jabber.developer
commented
why not auto_char? why not auto_char?
jabber.developer2
commented
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);
|
||||||
|
|||||||
@@ -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
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
|
|||||||
ff_verify_integrity(const gchar* const contact_barejid)
|
// Issue construction
|
||||||
{
|
// =========================================================================
|
||||||
GSList* issues = NULL;
|
|
||||||
|
|
||||||
if (!g_flatfile_account_jid) {
|
static integrity_issue_t*
|
||||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
_issue_new(integrity_level_t level, const char* file, int line, char* message)
|
||||||
issue->level = INTEGRITY_ERROR;
|
{
|
||||||
issue->file = g_strdup("N/A");
|
integrity_issue_t* issue = g_new0(integrity_issue_t, 1);
|
||||||
issue->line = 0;
|
issue->level = level;
|
||||||
issue->message = g_strdup("Flat-file backend not initialized");
|
issue->file = g_strdup(file ? file : "");
|
||||||
issues = g_slist_prepend(issues, issue);
|
issue->line = line;
|
||||||
return issues;
|
issue->message = message; // takes ownership of the (g_strdup'd) message
|
||||||
|
return issue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If contact specified, verify just that contact; otherwise discover all contacts
|
// =========================================================================
|
||||||
|
// 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/<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
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
|
|||||||
|
|
||||||
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
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).
|
||||||
|
|
||||||
// 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
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);
|
gsize len = strlen(buf);
|
||||||
@@ -266,27 +278,83 @@ 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);
|
||||||
return g_slist_reverse(issues);
|
return g_slist_reverse(issues);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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();
|
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
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` ("
|
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, "
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
jabber.developer
commented
Maybe Maybe `auto_char`, if `common.h` import is allowed?
jabber.developer2
commented
Corrected Corrected
|
|||||||
while (1) {
|
while (1) {
|
||||||
char* buf = ff_readline(fp, NULL);
|
auto_char char* buf = ff_readline(fp, NULL);
|
||||||
if (!buf)
|
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;
|
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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
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