TNT/src/message.c
m1ngsama d9382882d1 chore: bug fixes and code cleanup
Fixes:
- message_load() now holds g_message_file_lock for the read, so :last [N]
  can no longer observe a half-written line while message_save() is
  flushing.
- constant_time_strcmp() accumulates the length difference in size_t.
  The old code truncated to unsigned char, which collapsed pairs whose
  lengths differed by a multiple of 256 down to 0 and lost the signal.

Refactor:
- buffer_appendf() / buffer_append_bytes() moved to common.c; the two
  identical copies in ssh_server.c and tui.c have been removed.

Docs / cleanup:
- README clarifies that exec 'post' uses the SSH login name as the
  author and that anonymous mode performs no identity check.
- Removed TODO.md (both items completed) and docs/README.old.
- Trimmed the auto-generated 2025 entry block from docs/CHANGELOG.md
  and added a 2026-05-16 entry summarising this change.
2026-05-16 22:44:41 +08:00

353 lines
11 KiB
C

#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.h"
#include "utf8.h"
#include <unistd.h>
#include <fcntl.h>
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);
}
/* Initialize message subsystem */
void message_init(void) {
/* Nothing to initialize for now */
}
/* Load messages from log file - Optimized for large files.
* Holds g_message_file_lock for the duration of the read so concurrent
* message_save() calls from chat threads cannot interleave a partial line. */
int message_load(message_t **messages, int max_messages) {
char log_path[PATH_MAX];
/* Always allocate the message array */
message_t *msg_array = calloc(max_messages, sizeof(message_t));
if (!msg_array) {
return 0;
}
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
*messages = msg_array;
return 0;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
/* File doesn't exist yet, no messages */
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
/* Seek to end */
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
long file_size = ftell(fp);
if (file_size <= 0) {
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
/* Scan backwards to find the start position */
int newlines_found = 0;
long pos = file_size - 1;
/* Skip the very last byte if it's a newline */
if (pos >= 0) {
/* Read last char */
fseek(fp, pos, SEEK_SET);
if (fgetc(fp) == '\n') {
pos--;
}
}
/* Read backwards in chunks for performance */
#define CHUNK_SIZE 4096
char chunk[CHUNK_SIZE];
while (pos >= 0 && newlines_found < max_messages) {
long read_size = (pos >= CHUNK_SIZE) ? CHUNK_SIZE : (pos + 1);
long read_pos = pos - read_size + 1;
fseek(fp, read_pos, SEEK_SET);
if (fread(chunk, 1, read_size, fp) != (size_t)read_size) {
break;
}
/* Scan chunk backwards */
for (int i = read_size - 1; i >= 0; i--) {
if (chunk[i] == '\n') {
newlines_found++;
if (newlines_found >= max_messages) {
/* Found our start point: one char after this newline */
fseek(fp, read_pos + i + 1, SEEK_SET);
goto read_messages;
}
}
}
pos -= read_size;
}
/* If we got here, we reached start of file or didn't find enough newlines */
fseek(fp, 0, SEEK_SET);
read_messages:;
char line[2048];
int count = 0;
/* Now read forward */
while (fgets(line, sizeof(line), fp) && count < max_messages) {
/* Check for oversized lines */
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) {
/* Skip remainder of line */
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
continue;
}
/* Format: RFC3339_timestamp|username|content */
char line_copy[2048];
strncpy(line_copy, line, sizeof(line_copy) - 1);
line_copy[sizeof(line_copy) - 1] = '\0';
char *timestamp_str = strtok(line_copy, "|");
char *username = strtok(NULL, "|");
char *content = strtok(NULL, "\n");
/* Validate all fields exist and are non-empty */
if (!timestamp_str || !username || !content) {
continue;
}
if (username[0] == '\0') {
continue;
}
/* Validate field lengths */
if (strlen(username) >= MAX_USERNAME_LEN) {
continue;
}
if (strlen(content) >= MAX_MESSAGE_LEN) {
continue;
}
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
continue;
}
/* Parse strict UTC RFC3339 timestamp */
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) {
continue;
}
/* Validate timestamp is reasonable (not in far future or past) */
time_t now = time(NULL);
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
continue;
}
msg_array[count].timestamp = msg_time;
strncpy(msg_array[count].username, username, MAX_USERNAME_LEN - 1);
msg_array[count].username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(msg_array[count].content, content, MAX_MESSAGE_LEN - 1);
msg_array[count].content[MAX_MESSAGE_LEN - 1] = '\0';
count++;
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return count;
}
/* Save a message to log file */
int message_save(const message_t *msg) {
char log_path[PATH_MAX];
int rc = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
return -1;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "a");
if (!fp) {
pthread_mutex_unlock(&g_message_file_lock);
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];
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';
/* Replace pipe characters and newlines to prevent log format corruption */
for (char *p = safe_username; *p; p++) {
if (*p == '|' || *p == '\n' || *p == '\r') {
*p = '_';
}
}
for (char *p = safe_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 ||
fflush(fp) != 0) {
rc = -1;
}
/* Rotate if the log exceeds MAX_LOG_SIZE */
long file_size = ftell(fp);
fclose(fp);
if (file_size > MAX_LOG_SIZE) {
char backup_path[PATH_MAX + 4];
snprintf(backup_path, sizeof(backup_path), "%s.1", log_path);
rename(log_path, backup_path);
}
pthread_mutex_unlock(&g_message_file_lock);
return rc;
}
/* Search log file for messages whose username or content contains query.
* Case-insensitive. Returns the last max_results matches (most recent); caller frees *results. */
int message_search(const char *query, message_t **results, int max_results) {
char log_path[PATH_MAX];
message_t *res = calloc(max_results, sizeof(message_t));
if (!res) return 0;
if (!query || query[0] == '\0' ||
tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
*results = res;
return 0;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
pthread_mutex_unlock(&g_message_file_lock);
*results = res;
return 0;
}
char line[2048];
int count = 0;
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) {
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
continue;
}
char line_copy[2048];
strncpy(line_copy, line, sizeof(line_copy) - 1);
line_copy[sizeof(line_copy) - 1] = '\0';
char *timestamp_str = strtok(line_copy, "|");
char *username = strtok(NULL, "|");
char *content = strtok(NULL, "\n");
if (!timestamp_str || !username || !content || username[0] == '\0') continue;
if (strlen(username) >= MAX_USERNAME_LEN || strlen(content) >= MAX_MESSAGE_LEN) continue;
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) continue;
if (strcasestr(username, query) == NULL && strcasestr(content, query) == NULL) continue;
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) continue;
message_t m;
m.timestamp = msg_time;
strncpy(m.username, username, MAX_USERNAME_LEN - 1);
m.username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
m.content[MAX_MESSAGE_LEN - 1] = '\0';
if (count < max_results) {
res[count++] = m;
} else {
memmove(&res[0], &res[1], (max_results - 1) * sizeof(message_t));
res[max_results - 1] = m;
/* count stays at max_results */
}
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*results = res;
return (count < max_results) ? count : max_results;
}
/* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info;
localtime_r(&msg->timestamp, &tm_info);
char time_str[64];
strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M %Z", &tm_info);
int written = snprintf(buffer, buf_size, "[%s] %s: %s", time_str, msg->username, msg->content);
/* If snprintf truncated, the last UTF-8 character may be incomplete.
* Re-validate and trim any trailing partial sequence. */
if (written >= (int)buf_size) {
size_t len = strlen(buffer);
while (len > 0 && (buffer[len - 1] & 0xC0) == 0x80) {
len--; /* walk back continuation bytes */
}
if (len > 0 && (unsigned char)buffer[len - 1] >= 0xC0) {
/* This is a start byte whose sequence was truncated */
buffer[len - 1] = '\0';
}
}
/* Truncate to terminal width */
if (utf8_string_width(buffer) > width) {
utf8_truncate(buffer, width);
}
}