fix(review): address PR #94 review #37/#38 reviewer comments
Parser (src/database_flatfile_parser.c):
- ff_jid_to_dir: drop GString+step1, in-place mutate str_replace result
- ff_ensure_dir: g_file_test(G_FILE_TEST_IS_SYMLINK) instead of g_lstat
- ff_unescape_*: early-continue for non-escape branch (less nesting)
- ff_readline: strdup("") instead of malloc(1)+'\0' for skip-line return
- ff_write_line: auto_char safe_msg + single fprintf (drop GString full_line)
- ff_parse_line: early return on missing closing bracket;
extract metadata-parts loop into _ff_parse_meta_parts helper
Verify (src/database_flatfile_verify.c):
- Split monolithic ff_verify_integrity into _collect_contact_dirs,
_check_permissions, _first_pass, _lmc_pass, _verify_contact_dir
- _issue_new constructor; g_new0 throughout
- Separate seen_stanza_ids and seen_archive_ids tables (no cross-kind
duplicate confusion)
- ff_skip_bom in second pass (drop manual fgetc x3)
- log_debug when skipping a contact dir without history.log
- Doc comment on ff_verify_integrity in header
SQLite (src/database_sqlite.c):
- Doc block on ChatLogs schema (replaces_db_id <-> replaced_by_db_id
invariant maintained by AFTER INSERT trigger)
- log_error when _get_db_filename returns NULL during init
Flatfile dispatch (src/database_flatfile.c):
- Replace if/else with is_outgoing ternary for log-path contact pick
Tests:
- 6 new invalid-date parser tests: empty / partial ISO / garbage /
impossible calendar / negative year / far future timestamp
- New functional test message_db_history_multi_resource verifying
same-JID-different-resource history rehydration preserves resources
- test_database_stress.c LMC chain loop: auto_char buf, drop redundant
comment/empty pre-filter (ff_parse_line handles those)
This commit is contained in:
@@ -98,7 +98,7 @@ ff_get_message_enc_type(const char* const encstr)
|
||||
|
||||
// Sanitise a JID for use as a directory name.
|
||||
// 1. Replace '@' with '_at_'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..'
|
||||
// 2. Reject / strip path-separator and traversal characters: '/', '\\', '..'
|
||||
// This prevents a malicious federated JID like "../../../tmp/pwned" from
|
||||
// escaping the log directory tree.
|
||||
char*
|
||||
@@ -107,36 +107,32 @@ ff_jid_to_dir(const char* jid)
|
||||
if (!jid || jid[0] == '\0')
|
||||
return NULL;
|
||||
|
||||
// Replace '@' first
|
||||
char* step1 = str_replace(jid, "@", "_at_");
|
||||
if (!step1)
|
||||
// Replace '@' with '_at_' (str_replace returns a freshly allocated string)
|
||||
char* result = str_replace(jid, "@", "_at_");
|
||||
if (!result)
|
||||
return NULL;
|
||||
|
||||
// Replace '/' and '\\' with '_' to prevent path traversal
|
||||
GString* out = g_string_sized_new(strlen(step1));
|
||||
for (const char* p = step1; *p; p++) {
|
||||
if (*p == '/' || *p == '\\') {
|
||||
g_string_append_c(out, '_');
|
||||
} else {
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
// Replace path-separator characters in place
|
||||
for (char* p = result; *p; p++) {
|
||||
if (*p == '/' || *p == '\\')
|
||||
*p = '_';
|
||||
}
|
||||
free(step1);
|
||||
|
||||
// Collapse any remaining ".." sequences to "__" (belt-and-suspenders)
|
||||
char* result = g_string_free(out, FALSE);
|
||||
char* dotdot;
|
||||
while ((dotdot = strstr(result, "..")) != NULL) {
|
||||
dotdot[0] = '_';
|
||||
dotdot[1] = '_';
|
||||
}
|
||||
|
||||
// Reject empty result
|
||||
if (result[0] == '\0') {
|
||||
g_free(result);
|
||||
free(result);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
// Callers free via g_free, str_replace allocates via malloc — handover.
|
||||
char* gresult = g_strdup(result);
|
||||
free(result);
|
||||
return gresult;
|
||||
}
|
||||
|
||||
// Get the base directory for a contact's logs:
|
||||
@@ -172,15 +168,12 @@ ff_get_log_path(const char* contact_barejid)
|
||||
gboolean
|
||||
ff_ensure_dir(const char* path)
|
||||
{
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
|
||||
// Verify it's not a symlink
|
||||
struct stat st;
|
||||
if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
if (g_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
|
||||
log_error("flatfile: directory path is a symlink, refusing: %s", path);
|
||||
return FALSE;
|
||||
}
|
||||
if (g_file_test(path, G_FILE_TEST_IS_DIR))
|
||||
return TRUE;
|
||||
if (g_mkdir_with_parents(path, S_IRWXU) != 0) {
|
||||
log_error("flatfile: Could not create directory: %s (errno=%d)", path, errno);
|
||||
return FALSE;
|
||||
@@ -231,25 +224,25 @@ ff_unescape_message(const char* text)
|
||||
return g_strdup("");
|
||||
GString* out = g_string_sized_new(strlen(text));
|
||||
for (const char* p = text; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
@@ -296,31 +289,31 @@ ff_unescape_meta_value(const char* val)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(val));
|
||||
for (const char* p = val; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
switch (*p) {
|
||||
case '|':
|
||||
g_string_append_c(out, '|');
|
||||
break;
|
||||
case ']':
|
||||
g_string_append_c(out, ']');
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append_c(out, '\\');
|
||||
break;
|
||||
case 'n':
|
||||
g_string_append_c(out, '\n');
|
||||
break;
|
||||
case 'r':
|
||||
g_string_append_c(out, '\r');
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
@@ -376,11 +369,8 @@ ff_readline(FILE* fp, gboolean* truncated)
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
// Return empty string so caller's loop continues (parse will reject it).
|
||||
// Use malloc() so callers can always use free() consistently.
|
||||
char* empty = malloc(1);
|
||||
if (empty)
|
||||
empty[0] = '\0';
|
||||
return empty;
|
||||
// strdup() so callers can always free() consistently.
|
||||
return strdup("");
|
||||
}
|
||||
if (truncated)
|
||||
*truncated = FALSE;
|
||||
@@ -465,21 +455,14 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
|
||||
}
|
||||
|
||||
// Escape message body to prevent log injection
|
||||
char* safe_msg = ff_escape_message(message_text);
|
||||
auto_char char* safe_msg = ff_escape_message(message_text);
|
||||
|
||||
// Build complete line and write with a single fwrite()
|
||||
GString* full_line = g_string_new(NULL);
|
||||
g_string_printf(full_line, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
|
||||
size_t to_write = full_line->len;
|
||||
size_t written = fwrite(full_line->str, 1, to_write, fp);
|
||||
if (written != to_write) {
|
||||
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
|
||||
int ret = fprintf(fp, "%s %s %s: %s\n",
|
||||
timestamp, meta->str, sender->str, safe_msg);
|
||||
if (ret < 0) {
|
||||
log_error("flatfile: fprintf failed (errno=%d)", errno);
|
||||
}
|
||||
|
||||
g_string_free(full_line, TRUE);
|
||||
g_free(safe_msg);
|
||||
g_string_free(meta, TRUE);
|
||||
g_string_free(sender, TRUE);
|
||||
}
|
||||
@@ -554,19 +537,19 @@ ff_unescape_sender_resource(const char* res)
|
||||
return NULL;
|
||||
GString* out = g_string_sized_new(strlen(res));
|
||||
for (const char* p = res; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1)) {
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
if (*p != '\\' || !*(p + 1)) {
|
||||
g_string_append_c(out, *p);
|
||||
continue;
|
||||
}
|
||||
p++;
|
||||
if (*p == '\\') {
|
||||
g_string_append_c(out, '\\');
|
||||
} else if (*p == ':' && *(p + 1) == ' ') {
|
||||
g_string_append(out, ": ");
|
||||
p++; // skip the space
|
||||
} else {
|
||||
// Unknown escape — preserve literally
|
||||
g_string_append_c(out, '\\');
|
||||
g_string_append_c(out, *p);
|
||||
}
|
||||
}
|
||||
@@ -598,6 +581,33 @@ ff_parsed_line_free(ff_parsed_line_t* pl)
|
||||
g_free(pl);
|
||||
}
|
||||
|
||||
// Populate the parsed-line metadata fields from the pipe-split parts[]
|
||||
// (the contents of a "[...]" section). Index 0 is type, index 1 is enc;
|
||||
// remaining parts are key:value pairs (id:, aid:, corrects:, to:, to_res:, read:).
|
||||
static void
|
||||
_ff_parse_meta_parts(char** parts, ff_parsed_line_t* out)
|
||||
{
|
||||
for (int i = 0; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
out->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
out->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
|
||||
out->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
|
||||
out->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
out->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
out->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
out->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
out->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a single line. Returns NULL on parse failure.
|
||||
// Line format: {timestamp} [{metadata}] {sender}: {message}
|
||||
ff_parsed_line_t*
|
||||
@@ -652,68 +662,45 @@ ff_parse_line(const char* line)
|
||||
|
||||
// Parse metadata section [...]
|
||||
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
|
||||
if (bracket_end) {
|
||||
char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
|
||||
// Split by unescaped '|'
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
int i = 0;
|
||||
for (; parts[i]; i++) {
|
||||
if (i == 0) {
|
||||
result->type = g_strdup(parts[i]);
|
||||
} else if (i == 1) {
|
||||
result->enc = g_strdup(parts[i]);
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_ID)) {
|
||||
result->stanza_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_ID));
|
||||
} else if (g_str_has_prefix(parts[i], FF_META_PREFIX_AID)) {
|
||||
result->archive_id = ff_unescape_meta_value(parts[i] + strlen(FF_META_PREFIX_AID));
|
||||
} else if (g_str_has_prefix(parts[i], "corrects:")) {
|
||||
result->replace_id = ff_unescape_meta_value(parts[i] + strlen("corrects:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to:")) {
|
||||
result->to_jid = ff_unescape_meta_value(parts[i] + strlen("to:"));
|
||||
} else if (g_str_has_prefix(parts[i], "to_res:")) {
|
||||
result->to_resource = ff_unescape_meta_value(parts[i] + strlen("to_res:"));
|
||||
} else if (g_str_has_prefix(parts[i], "read:")) {
|
||||
result->marked_read = atoi(parts[i] + strlen("read:")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_free(meta_content);
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
char* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
g_free(raw_sender);
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else {
|
||||
if (!bracket_end) {
|
||||
// No closing bracket — malformed metadata
|
||||
ff_parsed_line_free(result);
|
||||
g_free(work);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
|
||||
char** parts = ff_split_meta(meta_content);
|
||||
if (parts) {
|
||||
_ff_parse_meta_parts(parts, result);
|
||||
g_strfreev(parts);
|
||||
}
|
||||
|
||||
// Parse sender: message after '] '
|
||||
const char* after_meta = bracket_end + 1;
|
||||
if (*after_meta == ' ')
|
||||
after_meta++;
|
||||
|
||||
// Find first *unescaped* ': ' which separates sender from message.
|
||||
const char* colon = ff_find_unescaped_colonspace(after_meta);
|
||||
if (colon) {
|
||||
auto_gchar gchar* raw_sender = g_strndup(after_meta, colon - after_meta);
|
||||
|
||||
// Split sender into jid/resource, then unescape resource
|
||||
char* slash = strchr(raw_sender, '/');
|
||||
if (slash) {
|
||||
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
|
||||
result->from_resource = ff_unescape_sender_resource(slash + 1);
|
||||
} else {
|
||||
result->from_jid = g_strdup(raw_sender);
|
||||
}
|
||||
|
||||
result->message = ff_unescape_message(colon + 2);
|
||||
} else {
|
||||
// No ': ' found, treat entire rest as message with unknown sender
|
||||
result->from_jid = g_strdup("unknown");
|
||||
result->message = ff_unescape_message(after_meta);
|
||||
}
|
||||
} else if (first_space) {
|
||||
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
|
||||
result->timestamp_str = g_strndup(work, first_space - work);
|
||||
|
||||
Reference in New Issue
Block a user