no-DB mode implementation #94
@@ -4,7 +4,9 @@ core_sources = \
|
||||
src/chatlog.c src/chatlog.h \
|
||||
src/database.h src/database.c \
|
||||
src/database_sqlite.c \
|
||||
src/database_flatfile.c \
|
||||
src/database_flatfile.c src/database_flatfile.h \
|
||||
src/database_flatfile_parser.c \
|
||||
src/database_flatfile_verify.c \
|
||||
src/log.h src/profanity.c src/common.h \
|
||||
src/profanity.h src/xmpp/chat_session.c \
|
||||
src/xmpp/chat_session.h src/xmpp/muc.c src/xmpp/muc.h src/xmpp/jid.h src/xmpp/jid.c \
|
||||
|
||||
110
src/database_flatfile.h
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* database_flatfile.h
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Internal header shared between database_flatfile*.c modules.
|
||||
* Not part of the public API — do not include from outside the flatfile backend.
|
||||
*/
|
||||
|
||||
#ifndef DATABASE_FLATFILE_H
|
||||
#define DATABASE_FLATFILE_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "database.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
|
||||
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
|
||||
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
|
||||
|
||||
// --- Shared global ---
|
||||
|
||||
// Account JID stored during init for path construction.
|
||||
// Defined in database_flatfile.c, used by all flatfile modules.
|
||||
extern char* g_flatfile_account_jid;
|
||||
|
||||
// --- Parsed line structure ---
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char* timestamp_str;
|
||||
GDateTime* timestamp;
|
||||
char* type;
|
||||
char* enc;
|
||||
char* stanza_id;
|
||||
char* archive_id;
|
||||
char* replace_id;
|
||||
char* from_jid;
|
||||
char* from_resource;
|
||||
char* message;
|
||||
} ff_parsed_line_t;
|
||||
|
||||
// --- Type conversion helpers ---
|
||||
|
||||
const char* ff_get_message_type_str(prof_msg_type_t type);
|
||||
prof_msg_type_t ff_get_message_type_type(const char* const type);
|
||||
const char* ff_get_message_enc_str(prof_enc_t enc);
|
||||
prof_enc_t ff_get_message_enc_type(const char* const encstr);
|
||||
|
||||
// --- Path helpers ---
|
||||
|
||||
char* ff_jid_to_dir(const char* jid);
|
||||
char* ff_get_contact_dir(const char* contact_barejid);
|
||||
char* ff_get_log_path(const char* contact_barejid, GDateTime* dt);
|
||||
gboolean ff_ensure_dir(const char* path);
|
||||
|
||||
// --- Escape / unescape ---
|
||||
|
||||
char* ff_escape_message(const char* text);
|
||||
char* ff_unescape_message(const char* text);
|
||||
char* ff_escape_meta_value(const char* val);
|
||||
char* ff_unescape_meta_value(const char* val);
|
||||
|
||||
// --- I/O ---
|
||||
|
||||
char* ff_readline(FILE* fp, gboolean* truncated);
|
||||
void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource, const char* message_text);
|
||||
|
||||
// --- Parser helpers ---
|
||||
|
||||
const char* ff_find_unescaped_char(const char* str, char ch);
|
||||
char** ff_split_meta(const char* meta);
|
||||
const char* ff_find_unescaped_colonspace(const char* str);
|
||||
char* ff_unescape_sender_resource(const char* res);
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
void ff_parsed_line_free(ff_parsed_line_t* pl);
|
||||
ff_parsed_line_t* ff_parse_line(const char* line);
|
||||
ProfMessage* ff_parsed_to_profmessage(ff_parsed_line_t* pl);
|
||||
|
||||
// --- Integrity verification (database_flatfile_verify.c) ---
|
||||
|
||||
GSList* ff_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
#endif
|
||||
752
src/database_flatfile_parser.c
Normal file
@@ -0,0 +1,752 @@
|
||||
/*
|
||||
* database_flatfile_parser.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Flat-file backend: type helpers, path helpers, escape/unescape,
|
||||
* line I/O, and the tolerant log-line parser.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
// =========================================================================
|
||||
// Type conversion helpers
|
||||
// =========================================================================
|
||||
|
||||
const char*
|
||||
ff_get_message_type_str(prof_msg_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case PROF_MSG_TYPE_CHAT:
|
||||
return "chat";
|
||||
case PROF_MSG_TYPE_MUC:
|
||||
return "muc";
|
||||
case PROF_MSG_TYPE_MUCPM:
|
||||
return "mucpm";
|
||||
case PROF_MSG_TYPE_UNINITIALIZED:
|
||||
return "chat";
|
||||
}
|
||||
return "chat";
|
||||
}
|
||||
|
||||
prof_msg_type_t
|
||||
ff_get_message_type_type(const char* const type)
|
||||
{
|
||||
if (g_strcmp0(type, "chat") == 0) {
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
} else if (g_strcmp0(type, "muc") == 0) {
|
||||
return PROF_MSG_TYPE_MUC;
|
||||
} else if (g_strcmp0(type, "mucpm") == 0) {
|
||||
return PROF_MSG_TYPE_MUCPM;
|
||||
}
|
||||
return PROF_MSG_TYPE_CHAT;
|
||||
}
|
||||
|
||||
const char*
|
||||
ff_get_message_enc_str(prof_enc_t enc)
|
||||
{
|
||||
switch (enc) {
|
||||
case PROF_MSG_ENC_OX:
|
||||
return "ox";
|
||||
case PROF_MSG_ENC_PGP:
|
||||
return "pgp";
|
||||
case PROF_MSG_ENC_OTR:
|
||||
return "otr";
|
||||
case PROF_MSG_ENC_OMEMO:
|
||||
return "omemo";
|
||||
case PROF_MSG_ENC_NONE:
|
||||
return "none";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
prof_enc_t
|
||||
ff_get_message_enc_type(const char* const encstr)
|
||||
{
|
||||
if (g_strcmp0(encstr, "ox") == 0) {
|
||||
return PROF_MSG_ENC_OX;
|
||||
} else if (g_strcmp0(encstr, "pgp") == 0) {
|
||||
return PROF_MSG_ENC_PGP;
|
||||
} else if (g_strcmp0(encstr, "otr") == 0) {
|
||||
return PROF_MSG_ENC_OTR;
|
||||
} else if (g_strcmp0(encstr, "omemo") == 0) {
|
||||
return PROF_MSG_ENC_OMEMO;
|
||||
}
|
||||
return PROF_MSG_ENC_NONE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Path helpers
|
||||
// =========================================================================
|
||||
|
||||
// Sanitise a JID for use as a directory name.
|
||||
// 1. Replace '@' with '_at_'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
|
||||
// This prevents a malicious federated JID like "../../../tmp/pwned" from
|
||||
// escaping the log directory tree.
|
||||
char*
|
||||
ff_jid_to_dir(const char* jid)
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
I don't understand the need for I don't understand the need for `gstring` and `step1`. I propose a simpler solution that might be more efficient, but I might've missed some intricacies:
```c
char* ff_jid_to_dir(const char* jid)
{
if (!jid || jid[0] == '\0')
return NULL;
// Replace '@' with "_at_"
char* result = str_replace(jid, "@", "_at_");
if (!result)
return NULL;
// Replace '/', '\\' with '_'
for (char* p = result; *p; p++) {
if (*p == '/' || *p == '\\') {
*p = '_';
}
}
// Collapse ".." to "__"
char* dotdot;
while ((dotdot = strstr(result, "..")) != NULL) {
dotdot[0] = '_';
dotdot[1] = '_';
}
// Reject empty result
if (result[0] == '\0') {
free(result);
return NULL;
}
return result;
}
```
jabber.developer2
commented
Corrected Corrected
|
||||
{
|
||||
if (!jid || jid[0] == '\0')
|
||||
return NULL;
|
||||
|
||||
// Replace '@' first
|
||||
char* step1 = str_replace(jid, "@", "_at_");
|
||||
if (!step1)
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the base directory for a contact's logs:
|
||||
// ~/.local/share/profanity/flatlog/{my_jid_dir}/{contact_jid_dir}/
|
||||
char*
|
||||
ff_get_contact_dir(const char* contact_barejid)
|
||||
{
|
||||
if (!g_flatfile_account_jid || !contact_barejid)
|
||||
return NULL;
|
||||
|
||||
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* contact_dir = ff_jid_to_dir(contact_barejid);
|
||||
|
||||
char* result = g_strdup_printf("%s/%s/%s", data_path, my_dir, contact_dir);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the log file path for a contact on a specific date:
|
||||
// {contact_dir}/{YYYY_MM_DD}.log
|
||||
char*
|
||||
ff_get_log_path(const char* contact_barejid, GDateTime* dt)
|
||||
{
|
||||
auto_gchar gchar* contact_dir = ff_get_contact_dir(contact_barejid);
|
||||
if (!contact_dir)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* date_str = g_date_time_format(dt, "%Y_%m_%d");
|
||||
char* result = g_strdup_printf("%s/%s.log", contact_dir, date_str);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ensure the directory exists, create if needed.
|
||||
// Refuses to follow symlinks at the final component.
|
||||
gboolean
|
||||
ff_ensure_dir(const char* path)
|
||||
{
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
|
||||
// Verify it's not a symlink
|
||||
|
jabber.developer marked this conversation as resolved
jabber.developer
commented
why not use why not use `g_file_test` in a similar fashion here?
`g_file_test(path, G_FILE_TEST_IS_SYMLINK)`
jabber.developer2
commented
Corrected Corrected
|
||||
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_mkdir_with_parents(path, S_IRWXU) != 0) {
|
||||
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Escape / unescape helpers
|
||||
// =========================================================================
|
||||
//
|
||||
// Message text and metadata values from remote peers can contain arbitrary
|
||||
// characters including newlines, pipes and brackets. Without escaping, a
|
||||
// crafted message could inject fake log lines (log injection / format
|
||||
// injection). We escape on write and unescape on read.
|
||||
|
||||
// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal
|
||||
char*
|
||||
ff_escape_message(const char* text)
|
||||
{
|
||||
if (!text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape message body: \\ -> \, \n -> newline, \r -> CR
|
||||
char*
|
||||
ff_unescape_message(const char* text)
|
||||
{
|
||||
if (!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 {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Escape metadata value (stanza_id, archive_id, replace_id):
|
||||
// these come from remote servers and may contain |, ], \, newlines.
|
||||
char*
|
||||
ff_escape_meta_value(const char* val)
|
||||
{
|
||||
if (!val || strlen(val) == 0)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append(out, "\\|");
|
||||
break;
|
||||
case ']':
|
||||
g_string_append(out, "\\]");
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append(out, "\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(out, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(out, "\\r");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// Unescape metadata value
|
||||
char*
|
||||
ff_unescape_meta_value(const char* val)
|
||||
{
|
||||
if (!val)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
|
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
|
||||
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 {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Readline helper
|
||||
// =========================================================================
|
||||
//
|
||||
// POSIX getline() for dynamic-length lines. Returns line without trailing
|
||||
// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
|
||||
// newline at EOF (partial write detection).
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
note for myself: come back to it and rereview note for myself: come back to it and rereview
|
||||
char*
|
||||
ff_readline(FILE* fp, gboolean* truncated)
|
||||
{
|
||||
char* line = NULL;
|
||||
size_t cap = 0;
|
||||
ssize_t nread = getline(&line, &cap, fp);
|
||||
if (nread == -1) {
|
||||
free(line);
|
||||
return NULL;
|
||||
}
|
||||
// Guard against pathological lines that could exhaust memory.
|
||||
// getline() already allocated, so we free and skip if too long.
|
||||
if (nread > FF_MAX_LINE_LEN) {
|
||||
log_error("flatfile: line too long (%zd bytes), skipping", nread);
|
||||
// Check newline termination before freeing
|
||||
gboolean had_newline = (nread > 0 && line[nread - 1] == '\n');
|
||||
free(line);
|
||||
// Skip to next newline if the overlength line wasn't newline-terminated
|
||||
if (!had_newline) {
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
// Return empty string so caller's loop continues (parse will reject it)
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
nit: Why not nit: Why not `strdup("")`?
jabber.developer2
commented
Corrected Corrected
|
||||
return g_strdup("");
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
if (nread > 0 && line[nread - 1] == '\n') {
|
||||
line[--nread] = '\0';
|
||||
} else if (feof(fp)) {
|
||||
// Line without trailing newline at EOF — likely a partial write
|
||||
if (truncated)
|
||||
*truncated = TRUE;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line writer
|
||||
// =========================================================================
|
||||
//
|
||||
// Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message}
|
||||
//
|
||||
// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r
|
||||
// Metadata values are escaped: |, ], \\, newline, CR
|
||||
// Lines starting with '#' are comments.
|
||||
// Empty lines are skipped.
|
||||
|
||||
void
|
||||
ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
const char* from_jid, const char* from_resource, const char* message_text)
|
||||
{
|
||||
// Escape metadata values from remote peers
|
||||
auto_gchar gchar* safe_sid = ff_escape_meta_value(stanza_id);
|
||||
auto_gchar gchar* safe_aid = ff_escape_meta_value(archive_id);
|
||||
auto_gchar gchar* safe_rid = ff_escape_meta_value(replace_id);
|
||||
|
||||
// Build metadata section: [type|enc|id:...|aid:...|corrects:...]
|
||||
GString* meta = g_string_new("[");
|
||||
g_string_append(meta, type ? type : "chat");
|
||||
g_string_append_c(meta, '|');
|
||||
g_string_append(meta, enc ? enc : "none");
|
||||
if (safe_sid) {
|
||||
g_string_append_printf(meta, "|id:%s", safe_sid);
|
||||
}
|
||||
if (safe_aid) {
|
||||
g_string_append_printf(meta, "|aid:%s", safe_aid);
|
||||
}
|
||||
if (safe_rid) {
|
||||
g_string_append_printf(meta, "|corrects:%s", safe_rid);
|
||||
}
|
||||
g_string_append_c(meta, ']');
|
||||
|
||||
// Build sender — escape ": " in the resource part to prevent
|
||||
// the parser from splitting at the wrong point.
|
||||
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 0) {
|
||||
// Escape backslash and colon-space in resource
|
||||
GString* safe_res = g_string_sized_new(strlen(from_resource));
|
||||
for (const char* p = from_resource; *p; p++) {
|
||||
if (*p == '\\') {
|
||||
g_string_append(safe_res, "\\\\");
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(safe_res, "\\: ");
|
||||
p++; // skip the space too
|
||||
} else {
|
||||
g_string_append_c(safe_res, *p);
|
||||
}
|
||||
}
|
||||
g_string_append_printf(sender, "/%s", safe_res->str);
|
||||
g_string_free(safe_res, TRUE);
|
||||
}
|
||||
|
||||
// Escape message body to prevent log injection
|
||||
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;
|
||||
ssize_t written = fwrite(full_line->str, 1, to_write, fp);
|
||||
if (written != (ssize_t)to_write) {
|
||||
log_error("flatfile: partial write (%zd/%zu)", written, to_write);
|
||||
}
|
||||
|
||||
g_string_free(full_line, TRUE);
|
||||
g_free(safe_msg);
|
||||
g_string_free(meta, TRUE);
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
why not auto_char? why not auto_char?
jabber.developer2
commented
Corrected Corrected
|
||||
g_string_free(sender, TRUE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Parser helpers
|
||||
// =========================================================================
|
||||
|
||||
// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash.
|
||||
const char*
|
||||
ff_find_unescaped_char(const char* str, char ch)
|
||||
{
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
I feel that it's overly complicated. Starting from the I feel that it's overly complicated. Starting from the `GString* full_line`, everything could be done in nearly a one-liner:
```c
int ret = fprintf(fp, "%s %s %s: %s\n",
timestamp, meta->str, sender->str, safe_msg);
if (ret < 0) {
log_error("flatfile: fprintf failed");
}
```
jabber.developer2
commented
Corrected Corrected
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ch)
|
||||
return p;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Split metadata content on unescaped '|'. Returns a NULL-terminated array.
|
||||
// Caller must g_strfreev() the result.
|
||||
char**
|
||||
ff_split_meta(const char* meta)
|
||||
{
|
||||
GPtrArray* arr = g_ptr_array_new();
|
||||
const char* start = meta;
|
||||
for (const char* p = meta;; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped char
|
||||
continue;
|
||||
}
|
||||
if (*p == '|' || *p == '\0') {
|
||||
g_ptr_array_add(arr, g_strndup(start, p - start));
|
||||
if (*p == '\0')
|
||||
break;
|
||||
start = p + 1;
|
||||
}
|
||||
}
|
||||
g_ptr_array_add(arr, NULL);
|
||||
return (char**)g_ptr_array_free(arr, FALSE);
|
||||
}
|
||||
|
||||
// Find first unescaped ": " (colon-space) in a string.
|
||||
const char*
|
||||
ff_find_unescaped_colonspace(const char* str)
|
||||
{
|
||||
if (!str)
|
||||
return NULL;
|
||||
for (const char* p = str; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++; // skip escaped character
|
||||
continue;
|
||||
}
|
||||
if (*p == ':' && *(p + 1) == ' ') {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Unescape a sender resource: "\\" -> '\', "\: " -> ": "
|
||||
char*
|
||||
ff_unescape_sender_resource(const char* res)
|
||||
{
|
||||
if (!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);
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Line parser
|
||||
// =========================================================================
|
||||
|
||||
void
|
||||
ff_parsed_line_free(ff_parsed_line_t* pl)
|
||||
{
|
||||
if (!pl)
|
||||
return;
|
||||
g_free(pl->timestamp_str);
|
||||
if (pl->timestamp)
|
||||
g_date_time_unref(pl->timestamp);
|
||||
g_free(pl->type);
|
||||
g_free(pl->enc);
|
||||
g_free(pl->stanza_id);
|
||||
g_free(pl->archive_id);
|
||||
g_free(pl->replace_id);
|
||||
g_free(pl->from_jid);
|
||||
g_free(pl->from_resource);
|
||||
g_free(pl->message);
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// Parse a single line. Returns NULL on parse failure.
|
||||
// Line format: {timestamp} [{metadata}] {sender}: {message}
|
||||
ff_parsed_line_t*
|
||||
ff_parse_line(const char* line)
|
||||
{
|
||||
if (!line || line[0] == '\0' || line[0] == '#') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Strip trailing \r if present (CRLF handling)
|
||||
char* work = g_strdup(line);
|
||||
gsize len = strlen(work);
|
||||
if (len > 0 && work[len - 1] == '\r') {
|
||||
work[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len == 0) {
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// UTF-8 validation
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(work, -1, &end)) {
|
||||
log_warning("flatfile: invalid UTF-8 at byte offset %ld", (long)(end - work));
|
||||
// Attempt Latin-1 fallback
|
||||
gsize br, bw;
|
||||
GError* err = NULL;
|
||||
char* converted = g_convert(work, -1, "UTF-8", "ISO-8859-1", &br, &bw, &err);
|
||||
if (converted) {
|
||||
g_free(work);
|
||||
work = converted;
|
||||
} else {
|
||||
if (err)
|
||||
g_error_free(err);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_t* result = g_malloc0(sizeof(ff_parsed_line_t));
|
||||
|
||||
// Parse timestamp — everything up to first space followed by '['
|
||||
char* bracket_start = strchr(work, '[');
|
||||
char* first_space = strchr(work, ' ');
|
||||
|
||||
if (bracket_start && first_space && first_space < bracket_start) {
|
||||
// Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
|
||||
// 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], "id:")) {
|
||||
result->stanza_id = ff_unescape_meta_value(parts[i] + 3);
|
||||
} else if (g_str_has_prefix(parts[i], "aid:")) {
|
||||
result->archive_id = ff_unescape_meta_value(parts[i] + 4);
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
result->replace_id = ff_unescape_meta_value(parts[i] + 9);
|
||||
}
|
||||
}
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
please make early return here to significantly reduce nesting please make early return here to significantly reduce nesting
jabber.developer2
commented
Corrected Corrected
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_free(meta_content);
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
|
jabber.developer marked this conversation as resolved
Outdated
jabber.developer
commented
it could be extracted to another method it could be extracted to another method
jabber.developer2
commented
Corrected Corrected
|
||||
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 {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
} else if (first_space) {
|
||||
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
result->type = g_strdup("chat");
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
char* rest = first_space + 1;
|
||||
// Skip " - " if present (chatlog.c format)
|
||||
if (g_str_has_prefix(rest, "- ")) {
|
||||
rest += 2;
|
||||
}
|
||||
|
||||
char* colon = strstr(rest, ": ");
|
||||
if (colon) {
|
||||
char* sender = g_strndup(rest, colon - rest);
|
||||
char* slash = strchr(sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(sender, slash - sender);
|
||||
result->from_resource = g_strdup(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(sender);
|
||||
}
|
||||
g_free(sender);
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(rest);
|
||||
}
|
||||
} else {
|
||||
// No space at all — can't parse
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
|
||||
if (!result->timestamp) {
|
||||
log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Default type/enc if missing
|
||||
if (!result->type)
|
||||
result->type = g_strdup("chat");
|
||||
if (!result->enc)
|
||||
result->enc = g_strdup("none");
|
||||
|
||||
g_free(work);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert parsed line to ProfMessage
|
||||
ProfMessage*
|
||||
ff_parsed_to_profmessage(ff_parsed_line_t* pl)
|
||||
{
|
||||
ProfMessage* msg = message_init();
|
||||
msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL;
|
||||
msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource);
|
||||
msg->plain = g_strdup(pl->message ? pl->message : "");
|
||||
msg->timestamp = g_date_time_ref(pl->timestamp);
|
||||
msg->type = ff_get_message_type_type(pl->type);
|
||||
msg->enc = ff_get_message_enc_type(pl->enc);
|
||||
return msg;
|
||||
}
|
||||
378
src/database_flatfile_verify.c
Normal file
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* database_flatfile_verify.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* Copyright (C) 2026 Profanity Contributors
|
||||
*
|
||||
* This file is part of Profanity.
|
||||
*
|
||||
* Profanity is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Profanity is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Flat-file backend: integrity verification (/history verify).
|
||||
* Checks: parsability, timestamp ordering, duplicate IDs, broken LMC
|
||||
* references, file permissions, BOM, CRLF, UTF-8, control chars.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "config/files.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
GSList*
|
||||
ff_verify_integrity(const gchar* const contact_barejid)
|
||||
|
jabber.developer marked this conversation as resolved
jabber.developer
commented
It's a really big function that should ideally be split for readability. Additionally, I'd like to see at least a short comment describing what's going on. Comment with higher level of abstraction in It's a really big function that should ideally be split for readability. Additionally, I'd like to see at least a short comment describing what's going on. Comment with higher level of abstraction in `.h` file and comment with more details in `.c` file so people that are reading header can understand how to use the function, while people that are reading (and likely prepare to edit) source code understand internals.
jabber.developer2
commented
Corrected Corrected
|
||||
{
|
||||
GSList* issues = NULL;
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
|
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->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("N/A");
|
||||
issue->line = 0;
|
||||
issue->message = g_strdup("Flat-file backend not initialized");
|
||||
issues = g_slist_append(issues, issue);
|
||||
return issues;
|
||||
}
|
||||
|
||||
// If contact specified, verify just that contact; otherwise discover all contacts
|
||||
GSList* contact_dirs = NULL;
|
||||
|
||||
if (contact_barejid) {
|
||||
auto_gchar gchar* cdir = ff_get_contact_dir(contact_barejid);
|
||||
if (cdir && g_file_test(cdir, G_FILE_TEST_IS_DIR)) {
|
||||
contact_dirs = g_slist_append(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_append(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_append(contact_dirs, full);
|
||||
} else {
|
||||
g_free(full);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each contact directory
|
||||
for (GSList* cd = contact_dirs; cd; cd = cd->next) {
|
||||
const char* cdir_path = cd->data;
|
||||
|
||||
GDir* dir = g_dir_open(cdir_path, 0, NULL);
|
||||
if (!dir)
|
||||
continue;
|
||||
|
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
|
||||
|
||||
GSList* log_files = NULL;
|
||||
const gchar* fname;
|
||||
while ((fname = g_dir_read_name(dir)) != NULL) {
|
||||
if (g_str_has_suffix(fname, ".log")) {
|
||||
log_files = g_slist_insert_sorted(log_files,
|
||||
g_strdup_printf("%s/%s", cdir_path, fname),
|
||||
(GCompareFunc)g_strcmp0);
|
||||
}
|
||||
}
|
||||
g_dir_close(dir);
|
||||
|
||||
GDateTime* prev_file_last_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);
|
||||
|
||||
for (GSList* lf = log_files; lf; lf = lf->next) {
|
||||
const char* filepath = lf->data;
|
||||
const char* basename = strrchr(filepath, '/');
|
||||
basename = basename ? basename + 1 : filepath;
|
||||
|
||||
// Check file permissions
|
||||
struct stat st;
|
||||
if (g_stat(filepath, &st) == 0) {
|
||||
if ((st.st_mode & 0777) != (S_IRUSR | S_IWUSR)) {
|
||||
|
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
|
||||
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_append(issues, issue);
|
||||
}
|
||||
}
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp)
|
||||
continue;
|
||||
|
||||
// BOM check
|
||||
|
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
|
||||
int c1 = fgetc(fp);
|
||||
int c2 = fgetc(fp);
|
||||
int c3 = fgetc(fp);
|
||||
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {
|
||||
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_append(issues, issue);
|
||||
} else {
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
GDateTime* prev_ts = NULL;
|
||||
GDateTime* first_ts = NULL;
|
||||
GDateTime* last_ts = NULL;
|
||||
gboolean has_crlf = FALSE;
|
||||
gboolean is_empty = TRUE;
|
||||
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
|
||||
// CRLF check
|
||||
if (len > 0 && buf[len - 1] == '\r') {
|
||||
has_crlf = TRUE;
|
||||
buf[--len] = '\0';
|
||||
}
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
is_empty = FALSE;
|
||||
|
||||
// UTF-8 validation
|
||||
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_append(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Control character check
|
||||
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_append(issues, issue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse line
|
||||
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_append(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
free(buf); // done with raw line
|
||||
|
||||
if (!first_ts)
|
||||
first_ts = g_date_time_ref(pl->timestamp);
|
||||
if (last_ts)
|
||||
g_date_time_unref(last_ts);
|
||||
last_ts = g_date_time_ref(pl->timestamp);
|
||||
|
||||
// Timestamp order within file
|
||||
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_append(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_append(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_append(issues, issue);
|
||||
} else {
|
||||
g_hash_table_insert(seen_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
}
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
|
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
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
||||
// CRLF warning for file
|
||||
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_append(issues, issue);
|
||||
}
|
||||
|
||||
// Empty file
|
||||
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_append(issues, issue);
|
||||
}
|
||||
|
||||
// Cross-file timestamp ordering
|
||||
if (first_ts && prev_file_last_ts) {
|
||||
if (g_date_time_compare(first_ts, prev_file_last_ts) < 0) {
|
||||
auto_gchar gchar* ts_first = g_date_time_format_iso8601(first_ts);
|
||||
auto_gchar gchar* ts_prev_last = g_date_time_format_iso8601(prev_file_last_ts);
|
||||
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("First timestamp (%s) is before previous file's last timestamp (%s)",
|
||||
ts_first, ts_prev_last);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
|
||||
if (prev_file_last_ts)
|
||||
g_date_time_unref(prev_file_last_ts);
|
||||
prev_file_last_ts = last_ts ? g_date_time_ref(last_ts) : NULL;
|
||||
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
if (first_ts)
|
||||
g_date_time_unref(first_ts);
|
||||
if (last_ts)
|
||||
g_date_time_unref(last_ts);
|
||||
}
|
||||
|
||||
// Second pass: check LMC references across all files
|
||||
for (GSList* lf = log_files; lf; lf = lf->next) {
|
||||
const char* filepath = lf->data;
|
||||
const char* basename_lmc = strrchr(filepath, '/');
|
||||
basename_lmc = basename_lmc ? basename_lmc + 1 : filepath;
|
||||
|
||||
FILE* fp = fopen(filepath, "r");
|
||||
if (!fp)
|
||||
continue;
|
||||
|
||||
// Skip BOM
|
||||
int b1 = fgetc(fp);
|
||||
int b2 = fgetc(fp);
|
||||
int b3 = fgetc(fp);
|
||||
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF)) {
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
int lineno = 0;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
lineno++;
|
||||
gsize len = strlen(buf);
|
||||
if (len > 0 && buf[len - 1] == '\r')
|
||||
buf[--len] = '\0';
|
||||
if (len == 0 || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
|
||||
if (pl->replace_id && 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_lmc);
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup_printf("Broken correction reference: corrects:%s not found", pl->replace_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
if (prev_file_last_ts)
|
||||
g_date_time_unref(prev_file_last_ts);
|
||||
g_hash_table_destroy(seen_ids);
|
||||
g_hash_table_destroy(all_stanza_ids);
|
||||
g_slist_free_full(log_files, g_free);
|
||||
}
|
||||
|
||||
g_slist_free_full(contact_dirs, g_free);
|
||||
return issues;
|
||||
}
|
||||
Same issue as earlier with copyright. We are not affiliated with Profanity.
Use the following lines for copyright notice:
Corrected