no-DB mode implementation #94
@@ -4,7 +4,9 @@ core_sources = \
|
|||||||
src/chatlog.c src/chatlog.h \
|
src/chatlog.c src/chatlog.h \
|
||||||
src/database.h src/database.c \
|
src/database.h src/database.c \
|
||||||
src/database_sqlite.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/log.h src/profanity.c src/common.h \
|
||||||
src/profanity.h src/xmpp/chat_session.c \
|
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 \
|
src/xmpp/chat_session.h src/xmpp/muc.c src/xmpp/muc.h src/xmpp/jid.h src/xmpp/jid.c \
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
110
src/database_flatfile.h
Normal file
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
|
||||||
|
*
|
||||||
|
* 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
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)
|
||||||
|
{
|
||||||
|
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
|
|||||||
|
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));
|
||||||
|
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).
|
||||||
|
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)
|
||||||
|
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);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g_strfreev(parts);
|
||||||
|
}
|
||||||
|
g_free(meta_content);
|
||||||
|
|
||||||
|
// Parse sender: message after '] '
|
||||||
|
const char* after_meta = bracket_end + 1;
|
||||||
|
if (*after_meta == ' ')
|
||||||
|
after_meta++;
|
||||||
|
|
||||||
|
// Find first *unescaped* ': ' which separates sender from message.
|
||||||
|
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||||
|
if (colon) {
|
||||||
|
char* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||||
|
|
||||||
|
// Split sender into jid/resource, then unescape resource
|
||||||
|
char* slash = strchr(raw_sender, '/');
|
||||||
|
if (slash) {
|
||||||
|
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||||
|
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||||
|
} else {
|
||||||
|
result->from_jid = g_strdup(raw_sender);
|
||||||
|
}
|
||||||
|
g_free(raw_sender);
|
||||||
|
|
||||||
|
result->message = ff_unescape_message(colon + 2);
|
||||||
|
} else {
|
||||||
|
// No ': ' found, treat entire rest as message with unknown sender
|
||||||
|
result->from_jid = g_strdup("unknown");
|
||||||
|
result->message = ff_unescape_message(after_meta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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
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));
|
||||||
|
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;
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
why not use
g_file_testin a similar fashion here?g_file_test(path, G_FILE_TEST_IS_SYMLINK)Corrected