diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 854294b..61d6975 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -63,6 +63,8 @@ - `scripts/logrotate.sh` now has validated arguments, stable exit statuses, dry-run support, archive retention, gzip-aware archives, and a regression test in the normal test suite. +- `messages.log` v1 record parsing and formatting now live in a dedicated + `message_log` module instead of being embedded in `message.c`. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index a52eceb..51003e4 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -81,6 +81,7 @@ src/ ├── exec.c - SSH exec command dispatch ├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) +├── message_log.c - messages.log v1 parsing and formatting ├── history_view.c - NORMAL-mode scroll window rules ├── tui.c - Terminal UI rendering (ANSI escape codes) ├── tui_status.c - Mode/status/input-line rendering @@ -103,6 +104,7 @@ include/ ├── bootstrap.h - SSH session bootstrap interface ├── chat_room.h - Chat room interface ├── message.h - Message structure and persistence +├── message_log.h - messages.log v1 parser/formatter interface ├── command_catalog.h - COMMAND-mode command metadata interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 4a0c688..1dfbbba 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -71,6 +71,7 @@ STRUCTURE src/exec_catalog.c SSH exec command matching, usage, argument shape src/exec.c SSH exec command dispatch src/message.c persistence, search + src/message_log.c messages.log v1 parsing and formatting src/history_view.c message viewport / scroll state src/help_text.c full-screen key reference text src/manual.c concise manual panel rendering diff --git a/include/message_log.h b/include/message_log.h new file mode 100644 index 0000000..7919ae9 --- /dev/null +++ b/include/message_log.h @@ -0,0 +1,21 @@ +#ifndef MESSAGE_LOG_H +#define MESSAGE_LOG_H + +#include "message.h" + +#define MESSAGE_LOG_MAX_LINE 2048 + +void message_log_format_timestamp_utc(time_t ts, char *buffer, + size_t buf_size); + +/* Parse one complete messages.log v1 record. `now` is used to reject records + * outside TNT's accepted replay window. */ +bool message_log_parse_record(const char *line, message_t *out, time_t now); + +/* Format one messages.log v1 record. record_len receives the number of bytes + * that would be written, excluding the trailing NUL. Passing NULL/0 for the + * output buffer is allowed when only the length is needed. */ +int message_log_format_record(const message_t *msg, char *buffer, + size_t buf_size, size_t *record_len); + +#endif /* MESSAGE_LOG_H */ diff --git a/src/message.c b/src/message.c index c71e6c1..1865937 100644 --- a/src/message.c +++ b/src/message.c @@ -1,10 +1,12 @@ #ifndef _DEFAULT_SOURCE -#define _DEFAULT_SOURCE /* for timegm() on glibc */ +#define _DEFAULT_SOURCE /* for strcasestr() on glibc */ #endif #if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) -#define _DARWIN_C_SOURCE /* for timegm() on macOS */ +#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */ #endif + #include "message.h" +#include "message_log.h" #include "utf8.h" #include #include @@ -12,32 +14,6 @@ static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER; -static time_t parse_rfc3339_utc(const char *timestamp_str) { - struct tm tm = {0}; - - if (!timestamp_str) { - return (time_t)-1; - } - - char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm); - if (!result || *result != '\0') { - return (time_t)-1; - } - - return timegm(&tm); -} - -static void format_rfc3339_utc(time_t ts, char *buffer, size_t buf_size) { - struct tm tm_info; - - if (!buffer || buf_size == 0) { - return; - } - - gmtime_r(&ts, &tm_info); - strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); -} - static void discard_line_remainder(FILE *fp) { int c; @@ -45,96 +21,23 @@ static void discard_line_remainder(FILE *fp) { } } -static bool parse_log_record(const char *line, message_t *out, - time_t now) { - char line_copy[2048]; - char *first_sep; - char *second_sep; - char *timestamp_str; - char *username; - char *content; - time_t msg_time; - size_t line_len; - - if (!line || !out) { - return false; - } - - line_len = strlen(line); - if (line_len == 0 || line[line_len - 1] != '\n') { - return false; - } - if (line_len >= sizeof(line_copy)) { - return false; - } - - memcpy(line_copy, line, line_len + 1); - line_copy[line_len - 1] = '\0'; - - first_sep = strchr(line_copy, '|'); - if (!first_sep) { - return false; - } - second_sep = strchr(first_sep + 1, '|'); - if (!second_sep || strchr(second_sep + 1, '|')) { - return false; - } - - *first_sep = '\0'; - *second_sep = '\0'; - timestamp_str = line_copy; - username = first_sep + 1; - content = second_sep + 1; - - if (timestamp_str[0] == '\0' || username[0] == '\0' || - content[0] == '\0') { - return false; - } - if (strlen(username) >= MAX_USERNAME_LEN || - strlen(content) >= MAX_MESSAGE_LEN) { - return false; - } - if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { - return false; - } - - msg_time = parse_rfc3339_utc(timestamp_str); - if (msg_time == (time_t)-1) { - return false; - } - if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { - return false; - } - - out->timestamp = msg_time; - strncpy(out->username, username, MAX_USERNAME_LEN - 1); - out->username[MAX_USERNAME_LEN - 1] = '\0'; - strncpy(out->content, content, MAX_MESSAGE_LEN - 1); - out->content[MAX_MESSAGE_LEN - 1] = '\0'; - return true; -} - static int append_dump_record(char **output, size_t *capacity, size_t *len, const message_t *msg) { - char timestamp[64]; - int needed; + size_t needed; size_t available; if (!output || !capacity || !len || !msg) { return -1; } - format_rfc3339_utc(msg->timestamp, timestamp, sizeof(timestamp)); - needed = snprintf(NULL, 0, "%s|%s|%s\n", timestamp, msg->username, - msg->content); - if (needed < 0) { + if (message_log_format_record(msg, NULL, 0, &needed) < 0) { return -1; } available = *capacity > *len ? *capacity - *len : 0; - if ((size_t)needed + 1 > available) { + if (needed + 1 > available) { size_t new_capacity = *capacity ? *capacity : 1024; - while ((size_t)needed + 1 > new_capacity - *len) { + while (needed + 1 > new_capacity - *len) { if (new_capacity > SIZE_MAX / 2) { return -1; } @@ -149,9 +52,11 @@ static int append_dump_record(char **output, size_t *capacity, *capacity = new_capacity; } - snprintf(*output + *len, *capacity - *len, "%s|%s|%s\n", timestamp, - msg->username, msg->content); - *len += (size_t)needed; + if (message_log_format_record(msg, *output + *len, *capacity - *len, + NULL) < 0) { + return -1; + } + *len += needed; return 0; } @@ -247,7 +152,7 @@ int message_load(message_t **messages, int max_messages) { fseek(fp, 0, SEEK_SET); read_messages:; - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; int count = 0; time_t now = time(NULL); @@ -261,7 +166,7 @@ read_messages:; } message_t parsed; - if (!parse_log_record(line, &parsed, now)) { + if (!message_log_parse_record(line, &parsed, now)) { continue; } @@ -277,6 +182,9 @@ read_messages:; /* Save a message to log file */ int message_save(const message_t *msg) { char log_path[PATH_MAX]; + message_t safe_msg; + char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48]; + size_t record_len = 0; int rc = 0; if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) { @@ -291,36 +199,29 @@ int message_save(const message_t *msg) { return -1; } - /* Format timestamp as RFC3339 */ - char timestamp[64]; - struct tm tm_info; - gmtime_r(&msg->timestamp, &tm_info); - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &tm_info); - /* Sanitize username and content to prevent log injection */ - char safe_username[MAX_USERNAME_LEN]; - char safe_content[MAX_MESSAGE_LEN]; + safe_msg.timestamp = msg->timestamp; + strncpy(safe_msg.username, msg->username, sizeof(safe_msg.username) - 1); + safe_msg.username[sizeof(safe_msg.username) - 1] = '\0'; - strncpy(safe_username, msg->username, sizeof(safe_username) - 1); - safe_username[sizeof(safe_username) - 1] = '\0'; - - strncpy(safe_content, msg->content, sizeof(safe_content) - 1); - safe_content[sizeof(safe_content) - 1] = '\0'; + strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1); + safe_msg.content[sizeof(safe_msg.content) - 1] = '\0'; /* Replace pipe characters and newlines to prevent log format corruption */ - for (char *p = safe_username; *p; p++) { + for (char *p = safe_msg.username; *p; p++) { if (*p == '|' || *p == '\n' || *p == '\r') { *p = '_'; } } - for (char *p = safe_content; *p; p++) { + for (char *p = safe_msg.content; *p; p++) { if (*p == '|' || *p == '\n' || *p == '\r') { *p = ' '; } } - /* Write to file: timestamp|username|content */ - if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 || + if (message_log_format_record(&safe_msg, record, sizeof(record), + &record_len) < 0 || + fwrite(record, 1, record_len, fp) != record_len || fflush(fp) != 0) { rc = -1; } @@ -361,7 +262,7 @@ int message_search(const char *query, message_t **results, int max_results) { return 0; } - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; int count = 0; time_t now = time(NULL); @@ -373,7 +274,7 @@ int message_search(const char *query, message_t **results, int max_results) { } message_t m; - if (!parse_log_record(line, &m, now)) continue; + if (!message_log_parse_record(line, &m, now)) continue; if (strcasestr(m.username, query) == NULL && strcasestr(m.content, query) == NULL) continue; @@ -440,7 +341,7 @@ int message_dump_text(char **output, size_t *output_len, int max_records) { return 0; } - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; time_t now = time(NULL); while (fgets(line, sizeof(line), fp)) { size_t line_len = strlen(line); @@ -450,7 +351,7 @@ int message_dump_text(char **output, size_t *output_len, int max_records) { } message_t parsed; - if (!parse_log_record(line, &parsed, now)) { + if (!message_log_parse_record(line, &parsed, now)) { continue; } diff --git a/src/message_log.c b/src/message_log.c new file mode 100644 index 0000000..72b884e --- /dev/null +++ b/src/message_log.c @@ -0,0 +1,129 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE /* for timegm() on glibc */ +#endif +#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) +#define _DARWIN_C_SOURCE /* for timegm() on macOS */ +#endif + +#include "message_log.h" +#include "utf8.h" + +static time_t parse_rfc3339_utc(const char *timestamp_str) { + struct tm tm = {0}; + + if (!timestamp_str) { + return (time_t)-1; + } + + char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm); + if (!result || *result != '\0') { + return (time_t)-1; + } + + return timegm(&tm); +} + +void message_log_format_timestamp_utc(time_t ts, char *buffer, + size_t buf_size) { + struct tm tm_info; + + if (!buffer || buf_size == 0) { + return; + } + + gmtime_r(&ts, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + +bool message_log_parse_record(const char *line, message_t *out, time_t now) { + char line_copy[MESSAGE_LOG_MAX_LINE]; + char *first_sep; + char *second_sep; + char *timestamp_str; + char *username; + char *content; + time_t msg_time; + size_t line_len; + + if (!line || !out) { + return false; + } + + line_len = strlen(line); + if (line_len == 0 || line[line_len - 1] != '\n') { + return false; + } + if (line_len >= sizeof(line_copy)) { + return false; + } + + memcpy(line_copy, line, line_len + 1); + line_copy[line_len - 1] = '\0'; + + first_sep = strchr(line_copy, '|'); + if (!first_sep) { + return false; + } + second_sep = strchr(first_sep + 1, '|'); + if (!second_sep || strchr(second_sep + 1, '|')) { + return false; + } + + *first_sep = '\0'; + *second_sep = '\0'; + timestamp_str = line_copy; + username = first_sep + 1; + content = second_sep + 1; + + if (timestamp_str[0] == '\0' || username[0] == '\0' || + content[0] == '\0') { + return false; + } + if (strlen(username) >= MAX_USERNAME_LEN || + strlen(content) >= MAX_MESSAGE_LEN) { + return false; + } + if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { + return false; + } + + msg_time = parse_rfc3339_utc(timestamp_str); + if (msg_time == (time_t)-1) { + return false; + } + if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { + return false; + } + + out->timestamp = msg_time; + strncpy(out->username, username, MAX_USERNAME_LEN - 1); + out->username[MAX_USERNAME_LEN - 1] = '\0'; + strncpy(out->content, content, MAX_MESSAGE_LEN - 1); + out->content[MAX_MESSAGE_LEN - 1] = '\0'; + return true; +} + +int message_log_format_record(const message_t *msg, char *buffer, + size_t buf_size, size_t *record_len) { + char timestamp[64]; + int needed; + + if (!msg) { + return -1; + } + + message_log_format_timestamp_utc(msg->timestamp, timestamp, + sizeof(timestamp)); + needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp, + msg->username, msg->content); + if (needed < 0) { + return -1; + } + if (record_len) { + *record_len = (size_t)needed; + } + if (!buffer || buf_size == 0) { + return 0; + } + return (size_t)needed < buf_size ? 0 : -1; +} diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 0a69667..c6667c6 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -12,6 +12,7 @@ endif # Source files UTF8_SRC = ../../src/utf8.c MESSAGE_SRC = ../../src/message.c +MESSAGE_LOG_SRC = ../../src/message_log.c COMMON_SRC = ../../src/common.c COMMAND_CATALOG_SRC = ../../src/command_catalog.c CLI_TEXT_SRC = ../../src/cli_text.c @@ -34,10 +35,10 @@ all: $(TESTS) test_utf8: test_utf8.c $(UTF8_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC) +test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC) +test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)