diff --git a/include/common.h b/include/common.h index 7f331b5..642aff8 100644 --- a/include/common.h +++ b/include/common.h @@ -14,7 +14,7 @@ #include "config_defaults.h" /* Project Metadata */ -#define TNT_VERSION "1.6.0" +#define TNT_VERSION "1.7.0" /* Public process/exec exit statuses. TNT follows the common sysexits(3) * convention for usage errors while keeping runtime failures portable. */ @@ -84,8 +84,8 @@ void buffer_appendf(char *buffer, size_t buf_size, size_t *pos, * non-numeric, or out of range. */ int env_int(const char *name, int fallback, int min_val, int max_val); -/* Reject usernames containing shell metacharacters, control characters, or - * a leading space/dot/dash. Used by username read, exec post (SSH login as +/* Reject usernames containing shell metacharacters, control characters, a + * check mark (the verified sign), or a leading space/dot/dash. Used by username read, exec post (SSH login as * author), and the :nick command. */ bool is_valid_username(const char *username); diff --git a/include/history_view.h b/include/history_view.h index 2725b06..b8731da 100644 --- a/include/history_view.h +++ b/include/history_view.h @@ -13,6 +13,15 @@ int history_view_height(int terminal_height, int input_rows); * messages fall off the bottom of a full screen. */ void history_view_message_time(const message_t *msg, char out[16]); int history_view_message_prefix_width(const message_t *msg); + +/* A verified sender carries a check mark after its name. For a verified + * action the actor's name moves into the prefix with the mark, so this is + * the length of that name in the content, or 0 when nothing moves. */ +#define HISTORY_VIEW_VERIFIED_MARK "✓" +size_t history_view_action_actor_len(const message_t *msg); + +/* The part of the content that wraps after the prefix. */ +const char *history_view_message_body(const message_t *msg); int history_view_message_content_width(const message_t *msg, int width); /* Display rows one message occupies at the given render width. Always at diff --git a/include/identity.h b/include/identity.h index bf0fdf5..acf0e4c 100644 --- a/include/identity.h +++ b/include/identity.h @@ -12,6 +12,7 @@ typedef struct { char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + char skeleton[TNT_IDENTITY_HANDLE_MAX + 1]; int key_count; char keys[TNT_IDENTITY_MAX_KEYS][TNT_IDENTITY_FINGERPRINT_SIZE]; } identity_entry_t; @@ -24,6 +25,14 @@ typedef struct { } identity_table_t; bool identity_handle_valid(const char *handle); + +/* The form two names share when they look alike: invisible characters and + * `_ - .` and space removed, fullwidth forms and common Cyrillic, Greek and + * Latin look-alikes mapped to ASCII, lowercased, then 0 1 i | read as o l l l + * and rn, vv as m, w. The m1ngsama ID server computes the same skeleton, so + * the two must change together. Bytes that are not valid UTF-8 are kept as + * they are. The skeleton is never longer than `name`. */ +void identity_skeleton(const char *name, char *out, size_t out_size); bool identity_fingerprint_valid(const char *fingerprint); /* Format `key` the way `ssh-keygen -l` does: "SHA256:" and unpadded base64. */ @@ -41,6 +50,9 @@ void identity_table_free(identity_table_t *table); /* Match `name` against handles without regard to ASCII letter case. */ const identity_entry_t *identity_table_find(const identity_table_t *table, const char *name); +/* Match `name` against handles by skeleton. */ +const identity_entry_t *identity_table_find_lookalike( + const identity_table_t *table, const char *name); const char *identity_table_handle_for_key(const identity_table_t *table, const char *fingerprint); @@ -52,7 +64,8 @@ bool identity_table_forget_key(identity_table_t *table, const char *fingerprint); /* The process-wide identities file in the state directory. Each call - * reloads the file when it changed on disk. */ + * reloads the file when it changed on disk. A name is reserved when it + * looks like a handle, not only when it equals one. */ bool identity_is_reserved(const char *name); bool identity_handle_for_key(const char *fingerprint, char *handle, size_t handle_size); diff --git a/include/input.h b/include/input.h index 19fc986..2d57098 100644 --- a/include/input.h +++ b/include/input.h @@ -32,5 +32,8 @@ void notify_mentions(const char *content, const client_t *sender); * Each returns false when no interactive session has that id. */ bool input_identity_notice(const char *session, const char *plain_text); bool input_identity_verified(const char *session, const char *handle); +/* Reserve `handle` without a key and rename every unverified session whose + * name looks like it. */ +int input_identity_reserve(const char *handle); #endif /* INPUT_H */ diff --git a/include/message.h b/include/message.h index 6b82073..3e00653 100644 --- a/include/message.h +++ b/include/message.h @@ -8,6 +8,7 @@ typedef struct { time_t timestamp; char username[MAX_USERNAME_LEN]; char content[MAX_MESSAGE_LEN]; + bool verified; /* The sender proved its identity */ /* Cached server-local display fields. A room message is formatted once * when inserted instead of making every connected TUI contend on the * platform timezone lock for every repaint. */ @@ -34,7 +35,8 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid * Returns the last max_results matches in chronological order; caller must free *results. */ int message_search(const char *query, message_t **results, int max_results); -/* Export valid persisted log records in messages.log v1 format. max_records +/* Export valid persisted log records without their flags field, as exec dump + * always has. max_records * 0 exports all valid records; positive values export the last max_records * valid records. Caller must free *output. */ int message_dump_text(char **output, size_t *output_len, int max_records); diff --git a/include/message_log.h b/include/message_log.h index 65e4d23..b6c20a8 100644 --- a/include/message_log.h +++ b/include/message_log.h @@ -8,12 +8,15 @@ void message_log_format_timestamp_utc(time_t ts, char *buffer, size_t buf_size); -/* First line of a migrated log. Both the old and the new parser treat it as - * an unparseable record and skip it, which is what makes it safe to add to a - * file an older binary may still read. */ -#define MESSAGE_LOG_HEADER "#tnt-message-log v2" +/* First line of a log in the current format. Every parser treats a marker + * line as an unparseable record and skips it. */ +#define MESSAGE_LOG_VERSION 3 +#define MESSAGE_LOG_HEADER "#tnt-message-log v3" +#define MESSAGE_LOG_FLAG_VERIFIED 'v' -bool message_log_is_header(const char *line); +/* The version a marker line names (2 or 3), or 0 when the line is not a + * marker. A file without a marker is version 1. */ +int message_log_header_version(const char *line); /* Content is stored escaped so a message may contain a newline while records * stay one per line: `\` becomes `\\`, a newline becomes `\n`. */ @@ -27,27 +30,34 @@ size_t message_log_encoded_length(const char *in); * a newline, and nothing else below 0x20, no DEL, no C1. */ bool message_log_content_has_forbidden_control(const char *s); -/* Rewrite one pre-v2 log line with its content field escaped, preserving the - * line's own terminator. A line that is not a record is copied through: the - * parser already skips it. False means the record cannot be represented in - * v2, so it is dropped rather than rewritten to decode as something else. */ -bool message_log_upgrade_record(const char *line, char *out, size_t out_size); - -/* Bring a pre-v2 log up to the escaped format in place: back it up to - * `.v1.bak`, escape every content field, and prepend the header. A - * missing file and a file that already carries the header are both no-ops, - * which is what makes this safe to run at every start. Returns -1 only when - * the log was left untouched by a failure. */ +/* Rewrite one line of a version `from_version` log as a current record, + * preserving the line's own terminator: a v1 content field is escaped, and + * every record gains an empty flags field. A line that is not a record is + * copied through: the parser already skips it. False means the record + * cannot be represented, so it is dropped rather than rewritten to decode as + * something else. */ +bool message_log_upgrade_record(const char *line, int from_version, char *out, + size_t out_size); + +/* Bring an older log up to the current format in place: back it up to + * `.v1.bak` or `.v2.bak`, upgrade every record, and prepend the + * current header. A missing file and a file that already carries the + * current header are both no-ops, which is what makes this safe to run at + * every start. Returns -1 only when the log was left untouched by a + * failure. */ int message_log_migrate(const char *path); -/* Parse one complete messages.log record and decode its content. `now` is - * used to reject records outside TNT's accepted replay window. */ +/* Parse one complete current record and decode its content. `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 record, escaping its content. 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); +/* Format one record, escaping its content. Without `with_flags` the record + * stops after the content field, the form exec dump has always written. + * 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, bool with_flags, + char *buffer, size_t buf_size, + size_t *record_len); #endif /* MESSAGE_LOG_H */ diff --git a/include/module_protocol.h b/include/module_protocol.h index 0aaa6c8..ac3384d 100644 --- a/include/module_protocol.h +++ b/include/module_protocol.h @@ -57,7 +57,7 @@ int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg, bool verified); + const message_t *msg); int tnt_module_append_presence(char *buffer, size_t buf_size, size_t *pos, const char *type, const char *nickname, diff --git a/include/module_runtime.h b/include/module_runtime.h index 40bacf2..88e2ee3 100644 --- a/include/module_runtime.h +++ b/include/module_runtime.h @@ -29,8 +29,7 @@ void tnt_module_runtime_shutdown(void); /* Queue a user/core-created public message for enabled modules. This is * intentionally fire-and-forget so basic chat never depends on module health. */ -void tnt_module_runtime_publish_message_created(const message_t *msg, - bool verified); +void tnt_module_runtime_publish_message_created(const message_t *msg); /* Track an interactive user joining or leaving and notify presence:read * modules. Safe to call with the room lock held. */ diff --git a/include/theme.h b/include/theme.h index 4bee251..5c9baf5 100644 --- a/include/theme.h +++ b/include/theme.h @@ -19,6 +19,7 @@ typedef struct { const char *accent_bold; /* bold accent, e.g. "\033[1;36m" */ const char *accent_dim; /* dim accent, e.g. "\033[2;36m" */ const char *accent_italic; /* italic accent, e.g. "\033[3;36m" */ + const char *verified; /* verified mark, never the accent colour */ } theme_t; /* Number of registered themes. */ diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index f4c6ea4..a850b62 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tnt-chat pkgdesc = SSH-native terminal chat server with a Vim-style interface - pkgver = 1.6.0 + pkgver = 1.7.0 pkgrel = 1 url = https://github.com/m1ngsama/TNT arch = x86_64 @@ -10,7 +10,7 @@ pkgbase = tnt-chat makedepends = make depends = libssh depends = openssh - source = tnt-chat-v1.6.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz + source = tnt-chat-v1.7.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.7.0/tnt-chat-v1.7.0-source.tar.gz source = tnt-chat.sysusers sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 89f63bc..55639fb 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: M1ng pkgname=tnt-chat -pkgver=1.6.0 +pkgver=1.7.0 pkgrel=1 pkgdesc='SSH-native terminal chat server with a Vim-style interface' arch=('x86_64' 'aarch64') diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index 3af5880..7e3ef81 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -1,3 +1,14 @@ +tnt-chat (1.7.0-1) unstable; urgency=medium + + * Treat a name that looks like a reserved handle as reserved, and rename + unverified sessions using one when identity.reserve reserves it. + * Mark messages from verified senders, and verified sessions in :users, + with a check mark. Nicknames may not contain check marks. + * Move messages.log to format v3, which records verified senders. v1 and + v2 logs are migrated in place at start. + + -- M1ng Wed, 14 Oct 2026 00:00:00 +0800 + tnt-chat (1.6.0-1) unstable; urgency=medium * Add the message:private module permission. Users the module reports diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 756df16..03609cf 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,7 +1,7 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz" + url "https://github.com/m1ngsama/TNT/releases/download/v1.7.0/tnt-chat-v1.7.0-source.tar.gz" sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" diff --git a/src/commands.c b/src/commands.c index 9bb7849..368a495 100644 --- a/src/commands.c +++ b/src/commands.c @@ -9,6 +9,7 @@ #include "client.h" #include "command_catalog.h" #include "common.h" +#include "history_view.h" #include "i18n.h" #include "identity.h" #include "keymap.h" @@ -53,12 +54,16 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos, } static bool append_message_row(char *output, size_t buf_size, size_t *pos, - const message_t *msg, const char *needle) { + const message_t *msg, const char *needle, + const theme_t *theme) { size_t mark = *pos; + size_t actor_len = history_view_action_actor_len(msg); + char actor[MAX_USERNAME_LEN]; char visible[MAX_MESSAGE_LEN]; richtext_run_t runs[RICHTEXT_MAX_RUNS]; - size_t run_count = richtext_parse(msg->content, visible, sizeof(visible), - NULL, runs, RICHTEXT_MAX_RUNS); + size_t run_count = richtext_parse(history_view_message_body(msg), visible, + sizeof(visible), NULL, runs, + RICHTEXT_MAX_RUNS); char ts[20]; struct tm tmi; @@ -66,7 +71,17 @@ static bool append_message_row(char *output, size_t buf_size, size_t *pos, strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); buffer_appendf(output, buf_size, pos, "[%s] ", ts); append_highlighted(output, buf_size, pos, msg->username, NULL, 0, needle); - buffer_appendf(output, buf_size, pos, ": "); + if (actor_len > 0) { + snprintf(actor, sizeof(actor), "%.*s", (int)actor_len, msg->content); + buffer_appendf(output, buf_size, pos, ": "); + append_highlighted(output, buf_size, pos, actor, NULL, 0, needle); + } + if (msg->verified) { + buffer_appendf(output, buf_size, pos, + " %s" HISTORY_VIEW_VERIFIED_MARK "\033[0m", + theme->verified); + } + buffer_appendf(output, buf_size, pos, actor_len > 0 ? " " : ": "); append_highlighted(output, buf_size, pos, visible, runs, run_count, needle); buffer_appendf(output, buf_size, pos, "\n"); @@ -304,7 +319,7 @@ int commands_rename(client_t *client, const char *new_name, bool verified) { client->ui_lang); room_broadcast(g_room, &nick_msg); message_save(&nick_msg); - tnt_module_runtime_publish_message_created(&nick_msg, false); + tnt_module_runtime_publish_message_created(&nick_msg); return 0; } @@ -436,9 +451,15 @@ void commands_dispatch(client_t *client) { } /* 1-column gutter: ▎ for you, blank for others */ buffer_appendf(output, sizeof(output), &pos, - "%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n", - is_self ? self_gutter : " ", - g_room->clients[i]->username, dur_str); + "%s \033[37m%s\033[0m", is_self ? self_gutter : " ", + g_room->clients[i]->username); + if (g_room->clients[i]->verified) { + buffer_appendf(output, sizeof(output), &pos, + " %s" HISTORY_VIEW_VERIFIED_MARK "\033[0m", + theme->verified); + } + buffer_appendf(output, sizeof(output), &pos, + " \033[2;37m· %s\033[0m\n", dur_str); } pthread_rwlock_unlock(&g_room->lock); for (int i = 0; i < remote_count; i++) { @@ -649,7 +670,8 @@ void commands_dispatch(client_t *client) { } for (int i = 0; i < last_count; i++) { if (!append_message_row(output, sizeof(output), &pos, - &last_msgs[start + i], NULL)) { + &last_msgs[start + i], NULL, + theme_resolve(client->theme_index))) { break; } } @@ -684,7 +706,8 @@ void commands_dispatch(client_t *client) { } for (int i = 0; i < display_count; i++) { if (!append_message_row(output, sizeof(output), &pos, - &found[start + i], query)) { + &found[start + i], query, + theme_resolve(client->theme_index))) { break; } } diff --git a/src/common.c b/src/common.c index 2ff9494..c8d2441 100644 --- a/src/common.c +++ b/src/common.c @@ -153,6 +153,14 @@ bool is_valid_username(const char *username) { return false; } + /* The chat view marks verified senders with a check mark. */ + static const char *const check_marks[] = {"✓", "✔", "☑", "✅"}; + for (size_t i = 0; i < sizeof(check_marks) / sizeof(check_marks[0]); i++) { + if (strstr(username, check_marks[i])) { + return false; + } + } + /* Check for illegal characters that could cause injection */ const char *illegal_chars = "|;&$`\n\r<>(){}[]'\"\\"; length = strlen(username); diff --git a/src/exec.c b/src/exec.c index 6bac005..9dde6ab 100644 --- a/src/exec.c +++ b/src/exec.c @@ -372,7 +372,6 @@ static int exec_command_post(client_t *client, const char *args) { char content[MAX_MESSAGE_LEN]; char username[MAX_USERNAME_LEN]; char handle[TNT_IDENTITY_HANDLE_MAX + 1]; - bool verified = false; message_t msg = { .timestamp = time(NULL), }; @@ -423,7 +422,7 @@ static int exec_command_post(client_t *client, const char *args) { return TNT_EXIT_ERROR; } snprintf(username, sizeof(username), "%s", handle); - verified = true; + msg.verified = true; } if (strncmp(content, "/me ", 4) == 0 && content[4] != '\0') { @@ -459,7 +458,7 @@ static int exec_command_post(client_t *client, const char *args) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created(&msg, verified); + tnt_module_runtime_publish_message_created(&msg); if (client_send(client, "posted\n", 7) != 0) { return TNT_EXIT_ERROR; diff --git a/src/history_view.c b/src/history_view.c index c11085d..67e7d2a 100644 --- a/src/history_view.c +++ b/src/history_view.c @@ -16,22 +16,46 @@ void history_view_message_time(const message_t *msg, char out[16]) { strftime(out, 16, "%H:%M", &tmi); } +size_t history_view_action_actor_len(const message_t *msg) { + const char *space; + + if (!msg || !msg->verified || strcmp(msg->username, "*") != 0) { + return 0; + } + space = strchr(msg->content, ' '); + return space && space != msg->content ? (size_t)(space - msg->content) + : 0; +} + +const char *history_view_message_body(const message_t *msg) { + size_t actor_len = history_view_action_actor_len(msg); + + return actor_len > 0 ? msg->content + actor_len + 1 : msg->content; +} + int history_view_message_prefix_width(const message_t *msg) { char time_str[16]; - char prefix_plain[MAX_USERNAME_LEN + 32]; + char prefix_plain[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 32]; + size_t actor_len; if (!msg) { return 0; } history_view_message_time(msg, time_str); + actor_len = history_view_action_actor_len(msg); if (system_message_is_system(msg)) { snprintf(prefix_plain, sizeof(prefix_plain), " --> "); + } else if (actor_len > 0) { + snprintf(prefix_plain, sizeof(prefix_plain), " %s * %.*s %s ", + time_str, (int)actor_len, msg->content, + HISTORY_VIEW_VERIFIED_MARK); } else if (strcmp(msg->username, "*") == 0) { snprintf(prefix_plain, sizeof(prefix_plain), " %s * ", time_str); } else { - snprintf(prefix_plain, sizeof(prefix_plain), " %s %s: ", time_str, - msg->username); + snprintf(prefix_plain, sizeof(prefix_plain), " %s %s%s%s: ", + time_str, msg->username, msg->verified ? " " : "", + msg->verified ? HISTORY_VIEW_VERIFIED_MARK : ""); } return utf8_string_width(prefix_plain); } @@ -52,8 +76,8 @@ int history_view_message_lines(const message_t *msg, int width) { /* Count rows over the same visible text the renderer draws, or markers * that never reach the screen push the count out by a row. */ - richtext_parse(msg->content, visible, sizeof(visible), NULL, NULL, - RICHTEXT_MAX_RUNS); + richtext_parse(history_view_message_body(msg), visible, sizeof(visible), + NULL, NULL, RICHTEXT_MAX_RUNS); rows = richtext_wrap(visible, history_view_message_content_width(msg, width), spans, HISTORY_VIEW_MAX_WRAPPED_ROWS); diff --git a/src/i18n_text.c b/src/i18n_text.c index 987d6e2..14b1884 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -186,9 +186,9 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "昵称已修改: %s -> %s\n" ), [I18N_NICK_RESERVED_FORMAT] = I18N_STRING( - "@%s belongs to a signed-in member.\n" + "@%s is taken by a signed-in member, or looks like their name.\n" "Use :login if it is you, or pick another name.\n", - "@%s 属于一位已登录的成员。\n" + "@%s 与一位已登录成员的名字相同或相似。\n" "如果是你本人,请用 :login 登录,否则请换一个名字。\n" ), [I18N_NICK_SIGNED_IN_FORMAT] = I18N_STRING( @@ -328,10 +328,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "post: 消息持久化失败\n" ), [I18N_EXEC_POST_RESERVED_FORMAT] = I18N_STRING( - "post: %s belongs to a signed-in member; " - "connect with that member's SSH key to post as it\n", - "post: %s 属于一位已登录的成员," - "只有用这位成员的 SSH 密钥连接才能以这个名字发帖\n" + "post: %s is taken by a signed-in member, or looks like their name; " + "only that member can post under it, with their SSH key\n", + "post: %s 与一位已登录成员的名字相同或相似," + "只有这位成员用自己的 SSH 密钥连接才能以这个名字发帖\n" ), [I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING( "exec: command too long\n", diff --git a/src/identity.c b/src/identity.c index 9a7a849..e442dba 100644 --- a/src/identity.c +++ b/src/identity.c @@ -1,5 +1,7 @@ #include "identity.h" +#include "utf8.h" + #include #include #include @@ -34,6 +36,111 @@ bool identity_handle_valid(const char *handle) { return true; } +static bool skeleton_drops(uint32_t cp) { + return cp == 0x00AD || (cp >= 0x200B && cp <= 0x200F) || + (cp >= 0x2060 && cp <= 0x2064) || cp == 0xFEFF || + (cp >= 0x0300 && cp <= 0x036F) || cp == '_' || cp == '-' || + cp == '.' || cp == ' '; +} + +static uint32_t skeleton_fold(uint32_t cp) { + static const struct { + uint16_t from; + char to; + } lookalikes[] = { + {0x0430, 'a'}, {0x0435, 'e'}, {0x043E, 'o'}, {0x0440, 'p'}, + {0x0441, 'c'}, {0x0443, 'y'}, {0x0445, 'x'}, {0x0456, 'i'}, + {0x0458, 'j'}, {0x0455, 's'}, {0x04BB, 'h'}, {0x0501, 'd'}, + {0x04CF, 'l'}, {0x0410, 'a'}, {0x0412, 'b'}, {0x0415, 'e'}, + {0x041A, 'k'}, {0x041C, 'm'}, {0x041D, 'h'}, {0x041E, 'o'}, + {0x0420, 'p'}, {0x0421, 'c'}, {0x0422, 't'}, {0x0425, 'x'}, + {0x0423, 'y'}, {0x0406, 'i'}, {0x0408, 'j'}, {0x0405, 's'}, + {0x03B1, 'a'}, {0x03BF, 'o'}, {0x03BD, 'v'}, {0x03C1, 'p'}, + {0x03B9, 'i'}, {0x03BA, 'k'}, {0x03C4, 't'}, {0x03C5, 'u'}, + {0x0391, 'a'}, {0x0392, 'b'}, {0x0395, 'e'}, {0x0396, 'z'}, + {0x0397, 'h'}, {0x0399, 'i'}, {0x039A, 'k'}, {0x039C, 'm'}, + {0x039D, 'n'}, {0x039F, 'o'}, {0x03A1, 'p'}, {0x03A4, 't'}, + {0x03A5, 'y'}, {0x03A7, 'x'}, {0x0131, 'i'}, {0x0269, 'i'}, + {0x2113, 'l'}, + }; + + if (cp >= 0xFF01 && cp <= 0xFF5E) { + cp -= 0xFEE0; + } + for (size_t i = 0; i < sizeof(lookalikes) / sizeof(lookalikes[0]); i++) { + if (lookalikes[i].from == cp) { + cp = (uint32_t)lookalikes[i].to; + break; + } + } + if (cp >= 'A' && cp <= 'Z') { + cp += 'a' - 'A'; + } + switch (cp) { + case '0': return 'o'; + case '1': case 'i': case '|': return 'l'; + default: return cp; + } +} + +static void skeleton_collapse(char *s, const char *pair, char single) { + char *out = s; + + for (const char *in = s; *in;) { + if (in[0] == pair[0] && in[1] == pair[1]) { + *out++ = single; + in += 2; + } else { + *out++ = *in++; + } + } + *out = '\0'; +} + +void identity_skeleton(const char *name, char *out, size_t out_size) { + size_t pos = 0; + + if (!out || out_size == 0) { + return; + } + for (const char *p = name ? name : ""; *p;) { + int len = utf8_byte_length((unsigned char)*p); + int used = 1; + uint32_t cp; + + if (!utf8_is_valid_sequence(p, len)) { + /* Kept byte for byte, so a broken name still compares. */ + if (pos + 1 >= out_size) { + break; + } + out[pos++] = *p++; + continue; + } + cp = utf8_decode(p, &used); + if (skeleton_drops(cp)) { + p += used; + continue; + } + cp = skeleton_fold(cp); + if (cp < 0x80) { + if (pos + 1 >= out_size) { + break; + } + out[pos++] = (char)cp; + } else { + if (pos + (size_t)used >= out_size) { + break; + } + memcpy(out + pos, p, (size_t)used); + pos += (size_t)used; + } + p += used; + } + out[pos] = '\0'; + skeleton_collapse(out, "rn", 'm'); + skeleton_collapse(out, "vv", 'w'); +} + bool identity_fingerprint_valid(const char *fingerprint) { if (!fingerprint || strncmp(fingerprint, FINGERPRINT_PREFIX, FINGERPRINT_PREFIX_LEN) != 0 || @@ -98,6 +205,22 @@ const identity_entry_t *identity_table_find(const identity_table_t *table, return NULL; } +const identity_entry_t *identity_table_find_lookalike( + const identity_table_t *table, const char *name) { + char skeleton[MAX_USERNAME_LEN]; + + if (!table || !name) { + return NULL; + } + identity_skeleton(name, skeleton, sizeof(skeleton)); + for (size_t i = 0; i < table->count; i++) { + if (strcmp(table->entries[i].skeleton, skeleton) == 0) { + return &table->entries[i]; + } + } + return NULL; +} + const char *identity_table_handle_for_key(const identity_table_t *table, const char *fingerprint) { if (!table || !fingerprint || fingerprint[0] == '\0') { @@ -158,6 +281,7 @@ int identity_table_add(identity_table_t *table, const char *handle, entry = &table->entries[table->count++]; memset(entry, 0, sizeof(*entry)); snprintf(entry->handle, sizeof(entry->handle), "%s", handle); + identity_skeleton(handle, entry->skeleton, sizeof(entry->skeleton)); } if (fingerprint) { @@ -322,7 +446,7 @@ bool identity_is_reserved(const char *name) { } pthread_mutex_lock(&g_identity_lock); (void)refresh_locked(path); - reserved = identity_table_find(&g_identities, name) != NULL; + reserved = identity_table_find_lookalike(&g_identities, name) != NULL; pthread_mutex_unlock(&g_identity_lock); return reserved; } diff --git a/src/input.c b/src/input.c index eea2448..e0205a6 100644 --- a/src/input.c +++ b/src/input.c @@ -312,20 +312,27 @@ bool input_identity_verified(const char *session, const char *handle) { return true; } -/* Sessions rename only themselves, so an unverified holder of a handle that - * just signed in is asked to step aside rather than renamed from here. */ +/* Sessions rename only themselves, so an unverified holder of a name that + * looks like a just-reserved handle is asked to step aside rather than + * renamed from here. */ static void evict_unverified_holders(const client_t *self, const char *handle) { + char handle_skeleton[MAX_USERNAME_LEN]; + char skeleton[MAX_USERNAME_LEN]; client_t **targets = NULL; int target_count = 0; + identity_skeleton(handle, handle_skeleton, sizeof(handle_skeleton)); pthread_rwlock_rdlock(&g_room->lock); if (g_room->client_count > 0) { targets = calloc((size_t)g_room->client_count, sizeof(*targets)); } for (int i = 0; targets && i < g_room->client_count; i++) { client_t *c = g_room->clients[i]; - if (c != self && !c->verified && - strcasecmp(c->username, handle) == 0) { + if (c == self || c->verified) { + continue; + } + identity_skeleton(c->username, skeleton, sizeof(skeleton)); + if (strcmp(skeleton, handle_skeleton) == 0) { client_addref(c); targets[target_count++] = c; } @@ -339,6 +346,15 @@ static void evict_unverified_holders(const client_t *self, const char *handle) { free(targets); } +int input_identity_reserve(const char *handle) { + int rc = identity_reserve(handle, NULL); + + if (rc == 0) { + evict_unverified_holders(NULL, handle); + } + return rc; +} + static void show_session_notice(client_t *client, const char *text) { snprintf(client->command_output, sizeof(client->command_output), "%s", text); @@ -1198,6 +1214,7 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, message_t msg = { .timestamp = time(NULL), + .verified = client->verified, }; if (is_action) { msg.username[0] = '*'; @@ -1214,8 +1231,7 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, if (message_save(&msg) == 0) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created( - &msg, client->verified); + tnt_module_runtime_publish_message_created(&msg); } else { fprintf(stderr, "interactive: failed to persist message\n"); } @@ -1695,7 +1711,7 @@ void input_run_session(client_t *client) { system_message_make_join(&join_msg, client->username, client->ui_lang); room_broadcast(g_room, &join_msg); message_save(&join_msg); - tnt_module_runtime_publish_message_created(&join_msg, false); + tnt_module_runtime_publish_message_created(&join_msg); /* Show MOTD if motd.txt exists in state directory */ { @@ -2094,7 +2110,7 @@ void input_run_session(client_t *client) { room_remove_client(g_room, client); room_broadcast(g_room, &leave_msg); message_save(&leave_msg); - tnt_module_runtime_publish_message_created(&leave_msg, false); + tnt_module_runtime_publish_message_created(&leave_msg); } /* Interactive shells otherwise look like a transport reset to OpenSSH diff --git a/src/message.c b/src/message.c index e71f6a3..b37b9a2 100644 --- a/src/message.c +++ b/src/message.c @@ -31,7 +31,7 @@ static int append_dump_record(char **output, size_t *capacity, return -1; } - if (message_log_format_record(msg, NULL, 0, &needed) < 0) { + if (message_log_format_record(msg, false, NULL, 0, &needed) < 0) { return -1; } @@ -53,8 +53,8 @@ static int append_dump_record(char **output, size_t *capacity, *capacity = new_capacity; } - if (message_log_format_record(msg, *output + *len, *capacity - *len, - NULL) < 0) { + if (message_log_format_record(msg, false, *output + *len, + *capacity - *len, NULL) < 0) { return -1; } *len += needed; @@ -105,13 +105,14 @@ int message_load(message_t **messages, int max_messages) { pthread_mutex_lock(&g_message_file_lock); - /* A pre-v2 log must be escaped before anything decodes it, or a stored - * `C:\new` reads back as `C:` plus a newline. */ + /* An older log must be upgraded before anything parses it: a v1 + * `C:\new` would read back as `C:` plus a newline, and no v2 record has + * the flags field the parser requires. */ if (message_log_migrate(log_path) < 0) { fprintf(stderr, - "message: could not migrate %s to the escaped log format; " - "history containing a backslash may read back wrong\n", - log_path); + "message: could not migrate %s to log format v%d; " + "older history will not be shown\n", + log_path, MESSAGE_LOG_VERSION); } FILE *fp = fopen(log_path, "r"); @@ -240,6 +241,7 @@ int message_save(const message_t *msg) { /* Sanitize username and content to prevent log injection */ safe_msg.timestamp = msg->timestamp; + safe_msg.verified = msg->verified; strncpy(safe_msg.username, msg->username, sizeof(safe_msg.username) - 1); safe_msg.username[sizeof(safe_msg.username) - 1] = '\0'; @@ -259,7 +261,7 @@ int message_save(const message_t *msg) { } } - if (message_log_format_record(&safe_msg, record, sizeof(record), + if (message_log_format_record(&safe_msg, true, record, sizeof(record), &record_len) < 0 || fwrite(record, 1, record_len, fp) != record_len || fflush(fp) != 0) { diff --git a/src/message_log.c b/src/message_log.c index 6294d14..66fb634 100644 --- a/src/message_log.c +++ b/src/message_log.c @@ -39,18 +39,19 @@ void message_log_format_timestamp_utc(time_t ts, char *buffer, strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); } -bool message_log_is_header(const char *line) { - size_t len; +int message_log_header_version(const char *line) { + static const char prefix[] = "#tnt-message-log v"; + size_t len = sizeof(prefix) - 1; - if (!line) { - return false; - } - len = strlen(MESSAGE_LOG_HEADER); - if (strncmp(line, MESSAGE_LOG_HEADER, len) != 0) { - return false; + if (!line || strncmp(line, prefix, len) != 0 || + (line[len] != '2' && line[len] != '3')) { + return 0; } /* Accept the line with or without its terminator. */ - return line[len] == '\0' || line[len] == '\n'; + if (line[len + 1] != '\0' && line[len + 1] != '\n') { + return 0; + } + return line[len] - '0'; } bool message_log_encode_content(const char *in, char *out, size_t out_size) { @@ -150,7 +151,8 @@ bool message_log_content_has_forbidden_control(const char *s) { return utf8_contains_control(stripped); } -bool message_log_upgrade_record(const char *line, char *out, size_t out_size) { +bool message_log_upgrade_record(const char *line, int from_version, char *out, + size_t out_size) { const char *first_sep; const char *second_sep; const char *content_start; @@ -159,7 +161,8 @@ bool message_log_upgrade_record(const char *line, char *out, size_t out_size) { size_t content_len; int written; - if (!line || !out || out_size == 0) { + if (!line || !out || out_size == 0 || from_version < 1 || + from_version >= MESSAGE_LOG_VERSION) { return false; } @@ -182,13 +185,17 @@ bool message_log_upgrade_record(const char *line, char *out, size_t out_size) { memcpy(content, content_start, content_len); content[content_len] = '\0'; - if (!message_log_encode_content(content, encoded, sizeof(encoded))) { - return false; + if (from_version == 1) { + if (!message_log_encode_content(content, encoded, sizeof(encoded))) { + return false; + } + } else { + memcpy(encoded, content, content_len + 1); } /* The original terminator is copied rather than rebuilt, so an * unterminated last line stays unterminated and still fails to parse. */ - written = snprintf(out, out_size, "%.*s%s%s", + written = snprintf(out, out_size, "%.*s%s|%s", (int)(content_start - line), line, encoded, content_start + content_len); return written >= 0 && (size_t)written < out_size; @@ -263,28 +270,35 @@ int message_log_migrate(const char *path) { FILE *in; FILE *out; bool at_record_start = true; + int version; int rc = 0; if (!path || path[0] == '\0') { return -1; } - if ((size_t)snprintf(backup, sizeof(backup), "%s.v1.bak", path) >= - sizeof(backup) || - (size_t)snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= sizeof(tmp)) { - return -1; - } in = fopen(path, "r"); if (!in) { return errno == ENOENT ? 0 : -1; } - if (!fgets(line, sizeof(line), in) || message_log_is_header(line)) { - /* Empty, or already v2. message_save writes the header when it - * creates the file, so a fresh install never migrates. */ + if (!fgets(line, sizeof(line), in) || + (version = message_log_header_version(line)) == MESSAGE_LOG_VERSION) { + /* Empty, or already current. message_save writes the header when + * it creates the file, so a fresh install never migrates. */ fclose(in); return 0; } - rewind(in); + if (version == 0) { + /* No marker: the first line is a v1 record. */ + version = 1; + rewind(in); + } + if ((size_t)snprintf(backup, sizeof(backup), "%s.v%d.bak", path, + version) >= sizeof(backup) || + (size_t)snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= sizeof(tmp)) { + fclose(in); + return -1; + } if (copy_file(path, backup) < 0) { fclose(in); @@ -308,7 +322,7 @@ int message_log_migrate(const char *path) { const char *emit = line; if (at_record_start && complete) { - if (!message_log_upgrade_record(line, migrated, + if (!message_log_upgrade_record(line, version, migrated, sizeof(migrated))) { at_record_start = true; continue; @@ -345,9 +359,11 @@ 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 *third_sep; char *timestamp_str; char *username; char *content; + char *flags; time_t msg_time; size_t line_len; char decoded[MAX_MESSAGE_LEN]; @@ -372,15 +388,26 @@ bool message_log_parse_record(const char *line, message_t *out, time_t now) { return false; } second_sep = strchr(first_sep + 1, '|'); - if (!second_sep || strchr(second_sep + 1, '|')) { + third_sep = second_sep ? strchr(second_sep + 1, '|') : NULL; + if (!third_sep || strchr(third_sep + 1, '|')) { return false; } *first_sep = '\0'; *second_sep = '\0'; + *third_sep = '\0'; timestamp_str = line_copy; username = first_sep + 1; content = second_sep + 1; + flags = third_sep + 1; + + /* Flags are single lowercase letters; ones this version does not know + * are ignored, so a later flag does not invalidate the record. */ + for (const char *f = flags; *f; f++) { + if (*f < 'a' || *f > 'z') { + return false; + } + } if (timestamp_str[0] == '\0' || username[0] == '\0' || content[0] == '\0') { @@ -415,13 +442,15 @@ bool message_log_parse_record(const char *line, message_t *out, time_t now) { out->timestamp = msg_time; snprintf(out->username, sizeof(out->username), "%s", username); snprintf(out->content, sizeof(out->content), "%s", decoded); + out->verified = strchr(flags, MESSAGE_LOG_FLAG_VERIFIED) != NULL; out->display_time[0] = '\0'; out->display_date[0] = '\0'; return true; } -int message_log_format_record(const message_t *msg, char *buffer, - size_t buf_size, size_t *record_len) { +int message_log_format_record(const message_t *msg, bool with_flags, + char *buffer, size_t buf_size, + size_t *record_len) { char timestamp[64]; int needed; @@ -442,8 +471,14 @@ int message_log_format_record(const message_t *msg, char *buffer, message_log_format_timestamp_utc(msg->timestamp, timestamp, sizeof(timestamp)); - needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp, - msg->username, encoded); + if (with_flags) { + needed = snprintf(buffer, buf_size, "%s|%s|%s|%s\n", timestamp, + msg->username, encoded, + msg->verified ? "v" : ""); + } else { + needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp, + msg->username, encoded); + } if (needed < 0) { return -1; } diff --git a/src/message_log_tool.c b/src/message_log_tool.c index 24f90fc..33a0cc0 100644 --- a/src/message_log_tool.c +++ b/src/message_log_tool.c @@ -23,7 +23,7 @@ static int print_recovered_record(const message_t *msg) { char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48]; size_t record_len = 0; - if (message_log_format_record(msg, record, sizeof(record), + if (message_log_format_record(msg, true, record, sizeof(record), &record_len) < 0) { return -1; } @@ -67,14 +67,14 @@ static int scan_log(const char *path, bool recover) { } report.format_version = 1; - if (fgets(line, sizeof(line), fp) && message_log_is_header(line)) { - report.format_version = 2; + if (fgets(line, sizeof(line), fp) && message_log_header_version(line)) { + report.format_version = message_log_header_version(line); line_no++; } else { rewind(fp); } - /* Recovered output is always v2, so it says so. */ + /* Recovered output is always current, so it says so. */ if (recover && fputs(MESSAGE_LOG_HEADER "\n", stdout) < 0) { fclose(fp); fprintf(stderr, "log: failed to write recovered output\n"); @@ -91,11 +91,12 @@ static int scan_log(const char *path, bool recover) { if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { discard_line_remainder(fp); - } else if (report.format_version == 1) { - /* Escape before parsing, or a v1 `C:\new` is read as a newline and - * the recovered file would mean something the original did not. */ - valid = message_log_upgrade_record(line, upgraded, - sizeof(upgraded)) && + } else if (report.format_version < MESSAGE_LOG_VERSION) { + /* Upgrade before parsing, or a v1 `C:\new` is read as a newline + * and the recovered file would mean something the original did + * not. */ + valid = message_log_upgrade_record(line, report.format_version, + upgraded, sizeof(upgraded)) && message_log_parse_record(upgraded, &parsed, now); } else { valid = message_log_parse_record(line, &parsed, now); diff --git a/src/module_protocol.c b/src/module_protocol.c index 7748ac4..19f9eb3 100644 --- a/src/module_protocol.c +++ b/src/module_protocol.c @@ -48,7 +48,7 @@ int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg, bool verified) { + const message_t *msg) { char timestamp[64]; size_t before; @@ -73,7 +73,7 @@ int tnt_module_append_message_created(char *buffer, size_t buf_size, "\"plain_text\":"); tnt_json_append_string(buffer, buf_size, pos, msg->content); buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{%s}}}\n", - verified ? "\"verified\":true" : ""); + msg->verified ? "\"verified\":true" : ""); return append_result(before, *pos, buf_size); } diff --git a/src/module_runtime.c b/src/module_runtime.c index e39a9ba..4a0809f 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -37,6 +37,7 @@ struct client; void notify_mentions(const char *content, const struct client *sender); bool input_identity_notice(const char *session, const char *plain_text); bool input_identity_verified(const char *session, const char *handle); +int input_identity_reserve(const char *handle); void commands_deliver_private(const char *from, const char *to, const char *plain_text); @@ -57,7 +58,6 @@ typedef struct module_event { message_t msg; char target[MAX_USERNAME_LEN]; uint64_t event_id; - bool verified; } module_event_t; typedef struct module_read_buffer { @@ -930,11 +930,10 @@ static bool online_update_locked(const char *nickname, bool joined) { } static int queue_event_locked(module_event_kind_t kind, const message_t *msg, - bool verified, const module_process_t *origin, + const module_process_t *origin, char saturated[][TNT_MODULE_NAME_MAX + 1]) { module_event_t event = { .kind = kind, - .verified = verified, }; int saturated_count = 0; @@ -979,7 +978,7 @@ static void report_saturated(char saturated[][TNT_MODULE_NAME_MAX + 1], } static void dispatch_event(module_event_kind_t kind, const message_t *msg, - bool verified, const module_process_t *origin) { + const module_process_t *origin) { char saturated[TNT_MAX_MODULES][TNT_MODULE_NAME_MAX + 1]; int saturated_count = 0; @@ -990,7 +989,7 @@ static void dispatch_event(module_event_kind_t kind, const message_t *msg, pthread_mutex_lock(&g_dispatch_lock); if (g_accepting && !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { - saturated_count = queue_event_locked(kind, msg, verified, origin, + saturated_count = queue_event_locked(kind, msg, origin, saturated); } pthread_mutex_unlock(&g_dispatch_lock); @@ -1045,7 +1044,7 @@ static void publish_module_message(const module_process_t *module, TNT_MODULE_NAME_MAX, module->manifest.name); snprintf(msg.content, sizeof(msg.content), "%s", plain_text); if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, false, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); } } @@ -1053,6 +1052,7 @@ static void publish_posted_message(const module_process_t *module, const tnt_module_message_post_t *post) { message_t msg = { .timestamp = time(NULL), + .verified = module->manifest.can_write_identity, }; if (strncmp(post->plain_text, "/me ", 4) == 0 && post->plain_text[4] != '\0') { @@ -1064,8 +1064,7 @@ static void publish_posted_message(const module_process_t *module, snprintf(msg.content, sizeof(msg.content), "%s", post->plain_text); } if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, - module->manifest.can_write_identity, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); } } @@ -1078,7 +1077,7 @@ static void apply_identity_record(const tnt_module_identity_record_t *record) { (void)input_identity_verified(record->session, record->handle); break; case TNT_MODULE_IDENTITY_RESERVE: - (void)identity_reserve(record->handle, NULL); + (void)input_identity_reserve(record->handle); break; } } @@ -1182,7 +1181,7 @@ static bool write_module_record(module_process_t *module, const char *record) { static void deliver_message_to_module(module_process_t *module, const message_t *msg, - uint64_t event_id, bool verified) { + uint64_t event_id) { char event[TNT_MODULE_LINE_MAX] = ""; char line[TNT_MODULE_LINE_MAX]; char message_id[64]; @@ -1193,7 +1192,7 @@ static void deliver_message_to_module(module_process_t *module, snprintf(message_id, sizeof(message_id), "local-%llu", (unsigned long long)event_id); if (tnt_module_append_message_created(event, sizeof(event), &pos, - message_id, msg, verified) < 0) { + message_id, msg) < 0) { disable_module(module, "event encoding failure"); return; } @@ -1293,7 +1292,7 @@ static void deliver_private_to_module(module_process_t *module, if (tnt_module_append_private_created( record, sizeof(record), &pos, event->msg.username, event->target, - event->msg.content, event->msg.timestamp, event->verified) < 0) { + event->msg.content, event->msg.timestamp, event->msg.verified) < 0) { disable_module(module, "event encoding failure"); return; } @@ -1350,8 +1349,7 @@ static void deliver_event_to_module(module_process_t *module, switch (event->kind) { case MODULE_EVENT_MESSAGE_CREATED: - deliver_message_to_module(module, &event->msg, event->event_id, - event->verified); + deliver_message_to_module(module, &event->msg, event->event_id); break; case MODULE_EVENT_IDENTITY_REQUESTED: deliver_identity_request_to_module(module, event); @@ -1627,7 +1625,7 @@ int tnt_module_runtime_init(void) { pthread_mutex_lock(&g_dispatch_lock); g_accepting = true; - (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, false, NULL, + (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, NULL, NULL); pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); @@ -1669,10 +1667,9 @@ void tnt_module_runtime_shutdown(void) { pthread_mutex_unlock(&g_lifecycle_lock); } -void tnt_module_runtime_publish_message_created(const message_t *msg, - bool verified) { +void tnt_module_runtime_publish_message_created(const message_t *msg) { if (msg) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, verified, NULL); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, NULL); } } @@ -1691,7 +1688,7 @@ void tnt_module_runtime_publish_presence(const char *nickname, bool joined) { !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { (void)queue_event_locked( joined ? MODULE_EVENT_PRESENCE_JOINED : MODULE_EVENT_PRESENCE_LEFT, - &msg, false, NULL, NULL); + &msg, NULL, NULL); } pthread_mutex_unlock(&g_dispatch_lock); } @@ -1745,7 +1742,7 @@ bool tnt_module_runtime_send_private(const char *from, const char *to, const char *plain_text, bool verified) { module_event_t event = { .kind = MODULE_EVENT_PRIVATE_CREATED, - .verified = verified, + .msg.verified = verified, }; module_queue_push_result_t pushed = MODULE_QUEUE_DROPPED; diff --git a/src/theme.c b/src/theme.c index 87e5a3d..d6204ab 100644 --- a/src/theme.c +++ b/src/theme.c @@ -4,25 +4,26 @@ #include /* Accent slot helper: normal / bold / dim / italic variants of one ANSI - * foreground colour code (30-37). Kept in the 16-colour space for maximum - * terminal compatibility. */ -#define THEME_ACCENT(name, code) \ + * foreground colour code (30-37), plus the bold colour of the verified mark. + * Kept in the 16-colour space for maximum terminal compatibility. */ +#define THEME_ACCENT(name, code, mark) \ { \ (name), \ "\033[" #code "m", \ "\033[1;" #code "m", \ "\033[2;" #code "m", \ - "\033[3;" #code "m" \ + "\033[3;" #code "m", \ + "\033[1;" #mark "m" \ } static const theme_t g_themes[] = { - THEME_ACCENT("cyan", 36), /* default */ - THEME_ACCENT("green", 32), - THEME_ACCENT("magenta", 35), - THEME_ACCENT("blue", 34), - THEME_ACCENT("amber", 33), - THEME_ACCENT("red", 31), - THEME_ACCENT("mono", 37), + THEME_ACCENT("cyan", 36, 32), /* default */ + THEME_ACCENT("green", 32, 36), + THEME_ACCENT("magenta", 35, 32), + THEME_ACCENT("blue", 34, 32), + THEME_ACCENT("amber", 33, 32), + THEME_ACCENT("red", 31, 32), + THEME_ACCENT("mono", 37, 97), }; #define THEME_COUNT (sizeof(g_themes) / sizeof(g_themes[0])) diff --git a/src/tui.c b/src/tui.c index 5d16f8f..57974fd 100644 --- a/src/tui.c +++ b/src/tui.c @@ -99,8 +99,9 @@ static bool format_message_row(const message_t *msg, int row, char *buffer, char visible[MAX_MESSAGE_LEN]; richtext_run_t runs[RICHTEXT_MAX_RUNS]; - size_t run_count = richtext_parse(msg->content, visible, sizeof(visible), - NULL, runs, RICHTEXT_MAX_RUNS); + size_t run_count = richtext_parse(history_view_message_body(msg), visible, + sizeof(visible), NULL, runs, + RICHTEXT_MAX_RUNS); richtext_span_t spans[HISTORY_VIEW_MAX_WRAPPED_ROWS]; size_t rows = richtext_wrap(visible, content_width, spans, @@ -126,18 +127,30 @@ static bool format_message_row(const message_t *msg, int row, char *buffer, return true; } + size_t actor_len = history_view_action_actor_len(msg); + char mark[32] = ""; + if (msg->verified) { + snprintf(mark, sizeof(mark), " %s" HISTORY_VIEW_VERIFIED_MARK + "\033[0m", theme->verified); + } + if (system_message_is_system(msg)) { snprintf(buffer, buf_size, "%s\033[90m--> %s\033[0m", gutter, row_text); + } else if (actor_len > 0) { + snprintf(buffer, buf_size, + "%s\033[90m%s\033[0m %s* %.*s\033[0m%s %s%s\033[0m", + gutter, time_str, theme->accent_italic, (int)actor_len, + msg->content, mark, theme->accent_italic, row_text); } else if (strcmp(msg->username, "*") == 0) { snprintf(buffer, buf_size, "%s\033[90m%s\033[0m %s* %s\033[0m", gutter, time_str, theme->accent_italic, row_text); } else { snprintf(buffer, buf_size, - "%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s", + "%s\033[90m%s\033[0m %s%s\033[0m%s: %s%s%s", gutter, time_str, username_color(msg->username), - msg->username, hl_start, row_text, hl_end); + msg->username, mark, hl_start, row_text, hl_end); } return true; } diff --git a/tests/test_cursor_editing.sh b/tests/test_cursor_editing.sh index b8645bc..9ca845c 100755 --- a/tests/test_cursor_editing.sh +++ b/tests/test_cursor_editing.sh @@ -60,7 +60,7 @@ send_repeat "\033\[D" 2 send_wait "\033\[3~" send_enter ' -check_case "delete" "|abc$" \ +check_case "delete" "|abc|$" \ "Delete removes the character under the caret" \ "Delete did not remove the character under the caret" diff --git a/tests/test_default_keymap.sh b/tests/test_default_keymap.sh index 262b650..31eb927 100755 --- a/tests/test_default_keymap.sh +++ b/tests/test_default_keymap.sh @@ -36,7 +36,7 @@ tnt_expect_session "plain" ' send_wait ":list" send_enter ' -check_case "plain" "|:list$" \ +check_case "plain" "|:list|$" \ "a colon types instead of opening COMMAND" \ "the colon was treated as a command prefix" @@ -46,7 +46,7 @@ send_lone_escape send_wait "after-escape" send_enter ' -check_case "plain" "|after-escape$" \ +check_case "plain" "|after-escape|$" \ "Esc does not leave the input" \ "Esc changed modes, so the following keys were lost" @@ -55,7 +55,7 @@ tnt_expect_session "plain" ' send_wait "/me waves" send_enter ' -check_case "plain" "|\*|plain waves$" \ +check_case "plain" "|\*|plain waves|$" \ "a leading slash runs the command" \ "/me was not dispatched as a command" @@ -65,7 +65,7 @@ tnt_expect_session "plain" ' send_wait "//slash" send_enter ' -check_case "plain" "|/slash$" \ +check_case "plain" "|/slash|$" \ "a doubled slash sends a literal slash" \ "// did not produce a literal slash" @@ -74,7 +74,7 @@ tnt_expect_session "plain" ' send_wait "/usr/local/bin is where it lives" send_enter ' -check_case "plain" "|/usr/local/bin is where it lives$" \ +check_case "plain" "|/usr/local/bin is where it lives|$" \ "an unknown slash word sends as text" \ "a path starting with / was swallowed" @@ -86,7 +86,7 @@ send -- "q" send_wait "after-pager" send_enter ' -check_case "plain" "|after-pager$" \ +check_case "plain" "|after-pager|$" \ "closing a pager returns to the input" \ "closing a pager left the session in NORMAL" diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index 242fc14..a50e5d3 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -197,6 +197,7 @@ ACTION_FIT_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post \ ACTION_FIT_RECORD=$(tail -n 1 "$STATE_DIR/messages.log") ACTION_FIT_CONTENT=${ACTION_FIT_RECORD#*|} ACTION_FIT_CONTENT=${ACTION_FIT_CONTENT#*|} +ACTION_FIT_CONTENT=${ACTION_FIT_CONTENT%|*} ACTION_FIT_LEN=$(printf '%s' "$ACTION_FIT_CONTENT" | wc -c | tr -d ' ') case "$ACTION_FIT_CONTENT" in *中) ACTION_FIT_UTF8=0 ;; diff --git a/tests/test_identity.sh b/tests/test_identity.sh index 1631de7..a64a9e3 100755 --- a/tests/test_identity.sh +++ b/tests/test_identity.sh @@ -1,6 +1,7 @@ #!/bin/sh # Verified identities: :login through an identity:write module, reserved -# names, :logout, SSH key sign-in, verified metadata, and exec post. +# and look-alike names, :logout, SSH key sign-in, the verified mark, verified +# metadata and log flags, and exec post. . ./lib.sh @@ -126,6 +127,9 @@ while IFS= read -r line; do printf '{"type":"identity.verified","session":"%s","handle":"alice"}\n' "$session" ;; *'"type":"message.created"'*'"plain_text":"web hello"'*) printf '{"type":"event.ok"}\n' ;; + *'"type":"message.created"'*'"plain_text":"reserve dora"'*) + printf '{"type":"identity.reserve","handle":"dora"}\n' + printf '{"type":"event.ok"}\n' ;; *'"type":"message.created"'*) printf '{"type":"event.ok"}\n' ;; esac @@ -207,12 +211,18 @@ want \$b "TEST-CODE" want \$b "Signed in as @alice." want \$b "ssh alice@localhost -p $PORT" -want \$a "belongs to a signed-in member" +want \$a "is taken by a signed-in member" send -i \$b -- "q" want \$b \$TNT_INPUT_PROMPT send -i \$b -- "hello verified\r" -want \$b "m: hello verified" +want \$b "alice\033\[0m \033\[1;32m\u2713\033\[0m: hello verified" +send -i \$b -- "/me waves\r" +want \$b "* alice\033\[0m \033\[1;32m\u2713\033\[0m \033\[3;36mwaves" +send -i \$b -- "/users\r" +want \$b "alice\033\[0m \033\[1;32m\u2713\033\[0m " +send -i \$b -- "q" +want \$b \$TNT_INPUT_PROMPT send -i \$b -- "/nick zed\r" want \$b "Use :logout before changing your name." send -i \$b -- "q" @@ -262,6 +272,17 @@ else cat "$TNT_IDENT_EVENTS" fi +if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v3" ] && + grep -q '|alice|hello verified|v$' "$LOG" && + grep -q '|\*|alice waves|v$' "$LOG" && + grep -q '|after logout|$' "$LOG" && + grep -q '|system|bob renamed to alice|$' "$LOG"; then + pass "the log records verified senders with the v flag" +else + fail "log flags unexpected" + cat "$LOG" +fi + if grep -q "^alice$" "$IDENTITIES"; then pass "a keyless sign-in reserves the handle without a key" else @@ -273,14 +294,14 @@ if run_session reserved " expect \"): \" send -- \"Alice\r\" expect { - \"@Alice belongs to a signed-in member.\" {} + \"@Alice is taken by a signed-in member, or looks like their name.\" {} timeout { exit 1 } } send -- \"q\" sleep 0.3 send -- \"/nick webby\r\" expect { - \"@webby belongs to a signed-in member.\" {} + \"@webby is taken by a signed-in member, or looks like their name.\" {} timeout { exit 1 } } " "$SSH_KEYLESS"; then @@ -290,6 +311,47 @@ else tnt_dump_session reserved fi +if run_session lookalike " +expect \"): \" +send -- \"AL1CE\r\" +expect { + \"@AL1CE is taken by a signed-in member, or looks like their name.\" {} + timeout { exit 1 } +} +send -- \"q\" +sleep 0.3 +send -- \"/nick vveb_by\r\" +expect { + \"@vveb_by is taken by a signed-in member, or looks like their name.\" {} + timeout { exit 1 } +} +" "$SSH_KEYLESS" && ! grep -q '|system|AL1CE joined' "$LOG" && + ! grep -q 'vveb_by' "$LOG"; then + pass "look-alike names are refused at the prompt and by :nick" +else + fail "a look-alike name was accepted" + tnt_dump_session lookalike +fi + +if run_session evict " +expect \"): \" +send -- \"D0RA\r\" +expect \"/help\" +send -- \"reserve dora\r\" +expect { + \"@D0RA is taken by a signed-in member, or looks like their name.\" {} + timeout { exit 1 } +} +" "$SSH_KEYLESS" && + tnt_wait_for "$LOG" '|system|D0RA renamed to anonymous[0-9]*|$' && + tnt_wait_for "$IDENTITIES" "^dora$"; then + pass "identity.reserve renames an unverified look-alike holder" +else + fail "identity.reserve left a look-alike holder in place" + tnt_dump_session evict + cat "$LOG" +fi + if run_session bindkey ' expect "): " send -- "carol\r" @@ -325,7 +387,8 @@ POST_OUTPUT=$($SSH_EXEC -i "$KEY" alice@127.0.0.1 post "from my key" 2>&1) POST_STATUS=$? if [ "$POST_STATUS" -eq 0 ] && [ "$POST_OUTPUT" = "posted" ] && tnt_wait_for_fixed "$TNT_IDENT_EVENTS" \ - '"plain_text":"from my key","metadata":{"verified":true}'; then + '"plain_text":"from my key","metadata":{"verified":true}' && + grep -q '|alice|from my key|v$' "$LOG"; then pass "exec post under a reserved name works with its key and is verified" else fail "exec post with the bound key: $POST_STATUS $POST_OUTPUT" @@ -334,7 +397,7 @@ fi POST_OUTPUT=$($SSH_EXEC -i "$OTHER_KEY" Alice@127.0.0.1 post "forged" 2>&1) POST_STATUS=$? if [ "$POST_STATUS" -eq 1 ] && - printf '%s' "$POST_OUTPUT" | grep -q "belongs to a signed-in member" && + printf '%s' "$POST_OUTPUT" | grep -q "is taken by a signed-in member" && ! grep -q "forged" "$LOG"; then pass "exec post under a reserved name is refused without its key" else @@ -348,7 +411,10 @@ expect { timeout { exit 1 } } send -- "q" -sleep 0.3 +expect { + -exact "alice\033\[0m \033\[1;32m\u2713\033\[0m: hello verified" {} + timeout { exit 1 } +} send -- "/logout\r" expect { "Signed out. Your name is now" {} diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index f0f44c3..56fbdfb 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -92,7 +92,7 @@ EOF if expect "$EXPECT_SCRIPT" >"$STATE_DIR/expect.log" 2>&1; then if grep -qF 'tester|line1\nline2\nline3' "$STATE_DIR/messages.log" && - ! grep -q 'tester|line1$' "$STATE_DIR/messages.log"; then + ! grep -q 'tester|line1|$' "$STATE_DIR/messages.log"; then echo "✓ bracketed paste becomes one message keeping its line breaks" PASS=$((PASS + 1)) else @@ -134,7 +134,7 @@ expect eof EOF if expect "$CONTROL_SCRIPT" >"$STATE_DIR/control-input.log" 2>&1 && - grep -q '|c1user|safetail$' "$STATE_DIR/messages.log"; then + grep -q '|c1user|safetail|$' "$STATE_DIR/messages.log"; then echo "✓ C1 terminal controls are dropped from username and paste" PASS=$((PASS + 1)) else @@ -168,6 +168,7 @@ if expect "$LONG_SCRIPT" >"$STATE_DIR/long-paste.log" 2>&1; then long_line=$(grep 'longer|' "$STATE_DIR/messages.log" | tail -1) content=${long_line#*|} content=${content#*|} + content=${content%|*} content_len=$(printf '%s' "$content" | wc -c | tr -d ' ') if [ "$content_len" -eq 1023 ]; then echo "✓ overlong paste is capped at message limit" @@ -212,6 +213,7 @@ if expect "$ACTION_SCRIPT" >"$STATE_DIR/action-boundary.log" 2>&1; then action_line=$(grep 'actor action-fit-' "$STATE_DIR/messages.log" | tail -1) action_content=${action_line#*|} action_content=${action_content#*|} + action_content=${action_content%|*} action_len=$(printf '%s' "$action_content" | wc -c | tr -d ' ') case "$action_content" in *中) action_utf8=0 ;; @@ -219,7 +221,7 @@ if expect "$ACTION_SCRIPT" >"$STATE_DIR/action-boundary.log" 2>&1; then esac if [ "$action_len" -eq 1023 ] && [ "$action_utf8" -eq 0 ] && ! grep -q 'action-reject-' "$STATE_DIR/messages.log" && - grep -q '|actor|after-action$' "$STATE_DIR/messages.log"; then + grep -q '|actor|after-action|$' "$STATE_DIR/messages.log"; then echo "✓ /me accepts exact fit and rejects expansion overflow" PASS=$((PASS + 1)) else @@ -608,7 +610,7 @@ fi scroll_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') scroll_i=1 while [ "$scroll_i" -le 30 ]; do - printf '%s|fixture|scroll fixture %02d\n' "$scroll_ts" "$scroll_i" >>"$STATE_DIR/messages.log" + printf '%s|fixture|scroll fixture %02d|\n' "$scroll_ts" "$scroll_i" >>"$STATE_DIR/messages.log" scroll_i=$((scroll_i + 1)) done diff --git a/tests/test_log_migration.sh b/tests/test_log_migration.sh index 6c4c0cd..8b4fe7d 100755 --- a/tests/test_log_migration.sh +++ b/tests/test_log_migration.sh @@ -1,5 +1,6 @@ #!/bin/sh -# End-to-end test: an existing v1 messages.log is migrated once, on startup. +# End-to-end test: an existing v1 or v2 messages.log is migrated once, on +# startup. PORT=${PORT:-12360} PASS=0 @@ -82,14 +83,15 @@ else fail "backup missing or altered" fi -if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v2" ]; then +if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v3" ]; then pass "the migrated log carries the header" else fail "header missing" sed -n '1,5p' "$LOG" fi -if grep -qF '|alice|C:\\new' "$LOG" && grep -qF '|bob|plain message' "$LOG"; then +if grep -qxF "$TS|alice|C:\\\\new|" "$LOG" && + grep -qxF "$TS|bob|plain message|" "$LOG"; then pass "the backslash record is escaped and the plain record is untouched" else fail "migrated content unexpected" @@ -97,7 +99,7 @@ else fi DUMP=$(ssh $SSH_OPTS anonymous@127.0.0.1 "dump --all" 2>/dev/null) -if printf '%s\n' "$DUMP" | grep -qF '|alice|C:\\new' && +if printf '%s\n' "$DUMP" | grep -qxF "$TS|alice|C:\\\\new" && [ "$(printf '%s\n' "$DUMP" | wc -l | tr -d ' ')" = "2" ]; then pass "dump emits the escaped form, one record per line" else @@ -120,6 +122,43 @@ fi stop_server +V2_BACKUP="$STATE_DIR/messages.log.v2.bak" +{ + printf '#tnt-message-log v2\n' + printf '%s|carol|D:\\\\old\n' "$TS" + printf '%s|dave|hello v2\n' "$TS" +} > "$SEED" +cp "$SEED" "$LOG" + +start_server + +if [ -f "$V2_BACKUP" ] && cmp -s "$SEED" "$V2_BACKUP"; then + pass "a v2 log is backed up verbatim" +else + fail "v2 backup missing or altered" +fi + +if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v3" ] && + [ "$(grep -c '' "$LOG")" = "3" ] && + grep -qxF "$TS|carol|D:\\\\old|" "$LOG" && + grep -qxF "$TS|dave|hello v2|" "$LOG"; then + pass "v2 records gain an empty flags field and keep their escapes" +else + fail "v2 migration unexpected" + cat "$LOG" +fi + +POST_OUTPUT=$(ssh $SSH_OPTS erin@127.0.0.1 post "after v2" 2>/dev/null) +if [ "$POST_OUTPUT" = "posted" ] && grep -qxF "$TS|dave|hello v2|" "$LOG" && + tail -n 1 "$LOG" | grep -q '|erin|after v2|$'; then + pass "new records are appended in v3 form after migration" +else + fail "append after v2 migration" + cat "$LOG" +fi + +stop_server + echo "" echo "PASSED: $PASS" echo "FAILED: $FAIL" diff --git a/tests/test_markdown_view.sh b/tests/test_markdown_view.sh index 13d8de4..28e43b6 100755 --- a/tests/test_markdown_view.sh +++ b/tests/test_markdown_view.sh @@ -95,7 +95,7 @@ else fi # The pager reads the log, so a fenced block can be seeded in its stored form. -printf '%s|author|```\\nfirst line\\nsecond line\\n```\n' \ +printf '%s|author|```\\nfirst line\\nsecond line\\n```|\n' \ "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >>"$LOG" PAGER_SCRIPT="$STATE_DIR/markdown-pager.expect" diff --git a/tests/test_message_log_tool.sh b/tests/test_message_log_tool.sh index 1a8c420..f294cf8 100755 --- a/tests/test_message_log_tool.sh +++ b/tests/test_message_log_tool.sh @@ -125,14 +125,48 @@ fi UPGRADED="$STATE_DIR/upgraded.log" "$BIN" --log-recover "$V1_LOG" > "$UPGRADED" 2>/dev/null -if head -n 1 "$UPGRADED" | grep -qx '#tnt-message-log v2' && - grep -qF 'C:\\new' "$UPGRADED"; then +if head -n 1 "$UPGRADED" | grep -qx '#tnt-message-log v3' && + grep -qxF "$TS|alice|C:\\\\new|" "$UPGRADED"; then pass "recover writes the header and escapes a v1 backslash" else fail "v1 recovery" cat "$UPGRADED" 2>/dev/null fi +UPGRADED_V2="$STATE_DIR/upgraded-v2.log" +"$BIN" --log-recover "$V2_LOG" > "$UPGRADED_V2" 2>/dev/null +if head -n 1 "$UPGRADED_V2" | grep -qx '#tnt-message-log v3' && + grep -qxF "$TS|alice|one|" "$UPGRADED_V2" && + grep -qxF "$TS|bob|C:\\\\new|" "$UPGRADED_V2"; then + pass "recover gives v2 records an empty flags field" +else + fail "v2 recovery" + cat "$UPGRADED_V2" 2>/dev/null +fi + +V3_LOG="$STATE_DIR/v3.log" +printf '#tnt-message-log v3\n' > "$V3_LOG" +printf '%s|alice|signed|v\n' "$TS" >> "$V3_LOG" +printf '%s|bob|later flag|vq\n' "$TS" >> "$V3_LOG" +printf '%s|carol|plain|\n' "$TS" >> "$V3_LOG" +printf '%s|dave|no flags field\n' "$TS" >> "$V3_LOG" +printf '%s|erin|bad flag|V\n' "$TS" >> "$V3_LOG" + +V3_RECOVERED="$STATE_DIR/v3-recovered.log" +V3_REPORT="$STATE_DIR/v3.report" +"$BIN" --log-recover "$V3_LOG" > "$V3_RECOVERED" 2> "$V3_REPORT" +if grep -q '^format_version 3$' "$V3_REPORT" && + grep -q '^valid_records 3$' "$V3_REPORT" && + grep -q '^first_invalid_line 5$' "$V3_REPORT" && + grep -qxF "$TS|alice|signed|v" "$V3_RECOVERED" && + grep -qxF "$TS|bob|later flag|v" "$V3_RECOVERED" && + grep -qxF "$TS|carol|plain|" "$V3_RECOVERED"; then + pass "v3 flags are checked, kept when known, and dropped when not" +else + fail "v3 recovery" + cat "$V3_RECOVERED" "$V3_REPORT" 2>/dev/null +fi + MISSING_OUTPUT=$("$BIN" --log-check "$STATE_DIR/missing.log" 2>&1) MISSING_STATUS=$? if [ "$MISSING_STATUS" -eq 1 ] && diff --git a/tests/test_multiline_input.sh b/tests/test_multiline_input.sh index b50fd40..744f45f 100755 --- a/tests/test_multiline_input.sh +++ b/tests/test_multiline_input.sh @@ -67,7 +67,7 @@ else cat "$LOG" 2>/dev/null fi -if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v2" ]; then +if [ "$(head -n 1 "$LOG")" = "#tnt-message-log v3" ]; then pass "a fresh log is v2" else fail "header missing" diff --git a/tests/test_vim_keymap.sh b/tests/test_vim_keymap.sh index 87ad67d..5125449 100755 --- a/tests/test_vim_keymap.sh +++ b/tests/test_vim_keymap.sh @@ -44,7 +44,7 @@ else printf '%s\n' "$MODES_OUTPUT" | tail -20 fi -if tnt_wait_for "$LOG" "|typed in insert$"; then +if tnt_wait_for "$LOG" "|typed in insert|$"; then pass "i returns to INSERT and Enter still sends" else fail "i did not return to INSERT" diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 3471aa7..cb83b5f 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -78,7 +78,7 @@ test_json_text: test_json_text.c $(JSON_TEXT_SRC) $(COMMON_SRC) test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(IDENTITY_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) -test_identity: test_identity.c $(IDENTITY_SRC) $(COMMON_SRC) +test_identity: test_identity.c $(IDENTITY_SRC) $(COMMON_SRC) $(UTF8_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) $(IDENTITY_SRC) diff --git a/tests/unit/test_common.c b/tests/unit/test_common.c index 68885ec..3297c22 100644 --- a/tests/unit/test_common.c +++ b/tests/unit/test_common.c @@ -17,6 +17,11 @@ static void test_username_validation(void) { assert(!is_valid_username("system")); assert(!is_valid_username("系统")); assert(!is_valid_username("module:roll-module")); + assert(!is_valid_username("alice ✓")); + assert(!is_valid_username("✔alice")); + assert(!is_valid_username("al☑ice")); + assert(!is_valid_username("alice✅")); + assert(is_valid_username("alice ✗")); } int main(void) { diff --git a/tests/unit/test_history_view.c b/tests/unit/test_history_view.c index 417b5f6..5168aa4 100644 --- a/tests/unit/test_history_view.c +++ b/tests/unit/test_history_view.c @@ -167,6 +167,32 @@ TEST(wrapped_rows_account_for_the_sender_prefix) { assert(history_view_message_lines(&msg, 80) == 16); } +TEST(verified_mark_widens_the_prefix) { + message_t msg = make_msg(0, "hello"); + int plain = history_view_message_prefix_width(&msg); + + msg.verified = true; + assert(history_view_message_prefix_width(&msg) == plain + 2); + assert(strcmp(history_view_message_body(&msg), "hello") == 0); + assert(history_view_action_actor_len(&msg) == 0); +} + +TEST(verified_action_moves_the_actor_into_the_prefix) { + message_t msg = make_msg(0, "alice waves hello"); + int plain; + + snprintf(msg.username, sizeof(msg.username), "*"); + plain = history_view_message_prefix_width(&msg); + assert(history_view_action_actor_len(&msg) == 0); + assert(strcmp(history_view_message_body(&msg), "alice waves hello") == 0); + + msg.verified = true; + assert(history_view_action_actor_len(&msg) == 5); + assert(strcmp(history_view_message_body(&msg), "waves hello") == 0); + /* "alice ✓ " joins the prefix. */ + assert(history_view_message_prefix_width(&msg) == plain + 8); +} + TEST(latest_slice_fits_the_screen_after_a_wrapped_message) { message_t messages[6]; @@ -210,6 +236,8 @@ int main(void) { RUN_TEST(wrapped_rows_account_for_the_sender_prefix); RUN_TEST(latest_slice_fits_the_screen_after_a_wrapped_message); RUN_TEST(latest_start_accounts_for_wrapped_rows); + RUN_TEST(verified_mark_widens_the_prefix); + RUN_TEST(verified_action_moves_the_actor_into_the_prefix); printf("\nAll %d tests passed!\n", tests_passed); return 0; diff --git a/tests/unit/test_identity.c b/tests/unit/test_identity.c index 8bc0705..bd8c3b4 100644 --- a/tests/unit/test_identity.c +++ b/tests/unit/test_identity.c @@ -58,6 +58,64 @@ TEST(validates_handles) { assert(!identity_handle_valid("用户")); } +static void assert_skeleton(const char *name, const char *expected) { + char out[64]; + + identity_skeleton(name, out, sizeof(out)); + if (strcmp(out, expected) != 0) { + printf("\nskeleton(\"%s\") = \"%s\", want \"%s\"\n", name, out, + expected); + assert(0); + } +} + +TEST(skeleton_folds_look_alikes) { + static const char *const mingsama[] = { + "m1ngsama", + "MINGSAMA", + "m1ng_sama", + "m1ngs\xd0\xb0ma", /* Cyrillic a */ + "m1ngsarna", + "\xef\xbd\x8d\xef\xbc\x91\xef\xbd\x8e\xef\xbd\x87" + "\xef\xbd\x93\xef\xbd\x81\xef\xbd\x8d\xef\xbd\x81", /* fullwidth */ + "m\xe2\x80\x8b" "1ngsama", /* zero-width space */ + }; + + for (size_t i = 0; i < sizeof(mingsama) / sizeof(mingsama[0]); i++) { + assert_skeleton(mingsama[i], "mlngsama"); + } + assert_skeleton("vvave", "wave"); + assert_skeleton("vvavve", "wawe"); + assert_skeleton("lune", "lune"); + assert_skeleton("rnrn", "mm"); + assert_skeleton("rrn", "rm"); + assert_skeleton("a-b.c d_e", "abcde"); + assert_skeleton("\xc3\xa9t\xc3\xa9", "\xc3\xa9t\xc3\xa9"); /* precomposed é kept */ + assert_skeleton("e\xcc\x81", "e"); /* combining acute removed */ + assert_skeleton("\xc2\xad" "ab\xef\xbb\xbf", "ab"); /* soft hyphen, BOM */ + assert_skeleton("\xce\x91\xce\xbd\xce\xbd\xce\xb1", "awa"); /* Greek Alpha nu nu alpha */ + assert_skeleton("\xe2\x84\x93" "0|", "lol"); /* script l, zero, bar */ + assert_skeleton("\xc4\xb1\xc9\xa9", "ll"); /* dotless i, iota */ + assert_skeleton("\xef\xbc\xbf", "_"); /* fullwidth low line kept */ + assert_skeleton("\xe7\x94\xa8\xe6\x88\xb7", "\xe7\x94\xa8\xe6\x88\xb7"); + assert_skeleton("", ""); +} + +TEST(skeleton_keeps_invalid_utf8_bytes) { + char out[8]; + + assert_skeleton("ab\xff" "1", "ab\xff" "l"); + assert_skeleton("\xd0", "\xd0"); /* truncated sequence */ + assert_skeleton("\xd0" "1", "\xd0" "l"); + assert_skeleton("\xc0\xae" "x", "\xc0\xae" "x"); /* overlong '.' is not a '.' */ + assert_skeleton("\xed\xa0\x80" "I", "\xed\xa0\x80" "l"); /* surrogate */ + + identity_skeleton("abcdefghijkl", out, sizeof(out)); + assert(strcmp(out, "abcdefg") == 0); + identity_skeleton(NULL, out, sizeof(out)); + assert(strcmp(out, "") == 0); +} + TEST(validates_fingerprints) { assert(identity_fingerprint_valid(KEY_SHA256)); assert(!identity_fingerprint_valid("SHA256:")); @@ -185,6 +243,9 @@ TEST(store_reserves_and_reloads_on_change) { assert(!identity_is_reserved("alice")); assert(identity_reserve("alice", KEY_SHA256) == 0); assert(identity_is_reserved("Alice")); + assert(identity_is_reserved("AL1CE")); + assert(identity_is_reserved("\xd0\xb0lice")); + assert(!identity_is_reserved("alicia")); assert(identity_handle_for_key(KEY_SHA256, handle, sizeof(handle))); assert(strcmp(handle, "alice") == 0); @@ -210,6 +271,8 @@ int main(void) { setenv("TNT_STATE_DIR", state_dir, 1); RUN_TEST(validates_handles); + RUN_TEST(skeleton_folds_look_alikes); + RUN_TEST(skeleton_keeps_invalid_utf8_bytes); RUN_TEST(validates_fingerprints); RUN_TEST(formats_fingerprints_like_ssh_keygen); RUN_TEST(parses_and_skips_invalid_lines); diff --git a/tests/unit/test_message.c b/tests/unit/test_message.c index ad61f81..f5e53ff 100644 --- a/tests/unit/test_message.c +++ b/tests/unit/test_message.c @@ -201,13 +201,45 @@ TEST(message_save_stamps_the_format_on_a_fresh_log) { fp = fopen(log_path, "r"); assert(fp != NULL); assert(fgets(line, sizeof(line), fp) != NULL); - assert(message_log_is_header(line)); + assert(message_log_header_version(line) == MESSAGE_LOG_VERSION); assert(fgets(line, sizeof(line), fp) != NULL); - assert(strstr(line, "|alice|hello\n") != NULL); + assert(strstr(line, "|alice|hello|\n") != NULL); fclose(fp); cleanup_state_dir(); } +TEST(message_save_records_a_verified_sender) { + message_t msg = { .timestamp = time(NULL), .verified = true }; + message_t *messages = NULL; + char log_path[PATH_MAX]; + char line[MESSAGE_LOG_MAX_LINE]; + FILE *fp; + + setup_state_dir(); + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + strcpy(msg.username, "alice"); + strcpy(msg.content, "signed"); + assert(message_save(&msg) == 0); + msg.verified = false; + strcpy(msg.content, "plain"); + assert(message_save(&msg) == 0); + + fp = fopen(log_path, "r"); + assert(fp != NULL); + assert(fgets(line, sizeof(line), fp) != NULL); + assert(fgets(line, sizeof(line), fp) != NULL); + assert(strstr(line, "|alice|signed|v\n") != NULL); + assert(fgets(line, sizeof(line), fp) != NULL); + assert(strstr(line, "|alice|plain|\n") != NULL); + fclose(fp); + + assert(message_load(&messages, 10) == 2); + assert(messages[0].verified); + assert(!messages[1].verified); + free(messages); + cleanup_state_dir(); +} + TEST(message_save_keeps_a_newline) { message_t msg = { .timestamp = time(NULL) }; message_t *messages = NULL; @@ -264,9 +296,9 @@ TEST(message_load_migrates_a_v1_log_once) { fp = fopen(log_path, "r"); assert(fp != NULL); assert(fgets(line, sizeof(line), fp) != NULL); - assert(message_log_is_header(line)); + assert(message_log_header_version(line) == MESSAGE_LOG_VERSION); assert(fgets(line, sizeof(line), fp) != NULL); - assert(strstr(line, "|alice|C:\\\\new\n") != NULL); + assert(strstr(line, "|alice|C:\\\\new|\n") != NULL); fclose(fp); /* A second load must leave the escaped form alone. */ @@ -316,9 +348,11 @@ TEST(message_search_skips_malformed_records) { FILE *fp = fopen(log_path, "wb"); assert(fp != NULL); - fprintf(fp, "%s|alice|needle valid\n", ts); - fprintf(fp, "%s|mallory|needle extra|pipe\n", ts); - fprintf(fp, "%s|partial|needle truncated", ts); + fprintf(fp, MESSAGE_LOG_HEADER "\n"); + fprintf(fp, "%s|alice|needle valid|\n", ts); + fprintf(fp, "%s|mallory|needle extra|pipe|\n", ts); + fprintf(fp, "%s|old|needle without flags\n", ts); + fprintf(fp, "%s|partial|needle truncated|", ts); fclose(fp); int count = message_search("needle", &results, 10); @@ -341,7 +375,7 @@ TEST(message_search_keeps_last_matches_in_order) { FILE *fp = fopen(log_path, "wb"); assert(fp != NULL); for (int i = 0; i < 5; i++) { - fprintf(fp, "%s|user%d|needle %d\n", ts, i, i); + fprintf(fp, "%s|user%d|needle %d|\n", ts, i, i); } fclose(fp); @@ -367,11 +401,12 @@ TEST(message_dump_exports_valid_records) { FILE *fp = fopen(log_path, "wb"); assert(fp != NULL); - fprintf(fp, "%s|alice|first valid\n", ts); - fprintf(fp, "%s|mallory|extra|pipe\n", ts); - fprintf(fp, "%s|bob|second valid\n", ts); - fprintf(fp, "%s|carol|third valid\n", ts); - fprintf(fp, "%s|partial|truncated record", ts); + fprintf(fp, MESSAGE_LOG_HEADER "\n"); + fprintf(fp, "%s|alice|first valid|\n", ts); + fprintf(fp, "%s|mallory|extra|pipe|\n", ts); + fprintf(fp, "%s|bob|second valid|v\n", ts); + fprintf(fp, "%s|carol|third valid|\n", ts); + fprintf(fp, "%s|partial|truncated record|", ts); fclose(fp); snprintf(expected_all, sizeof(expected_all), @@ -496,6 +531,7 @@ int main(void) { RUN_TEST(message_save_basic); RUN_TEST(message_save_rejects_control_characters); RUN_TEST(message_save_stamps_the_format_on_a_fresh_log); + RUN_TEST(message_save_records_a_verified_sender); RUN_TEST(message_save_keeps_a_newline); RUN_TEST(message_save_still_flattens_a_carriage_return); RUN_TEST(message_load_migrates_a_v1_log_once); diff --git a/tests/unit/test_message_log.c b/tests/unit/test_message_log.c index ab24746..67fbfc3 100644 --- a/tests/unit/test_message_log.c +++ b/tests/unit/test_message_log.c @@ -91,7 +91,7 @@ TEST(record_round_trip_carries_a_newline) { snprintf(msg.username, sizeof(msg.username), "alice"); snprintf(msg.content, sizeof(msg.content), "first\nsecond"); - assert(message_log_format_record(&msg, line, sizeof(line), NULL) == 0); + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) == 0); assert(strstr(line, "first\\nsecond") != NULL); /* stored escaped */ assert(strchr(line, '\n') == line + strlen(line) - 1); /* still one line */ @@ -107,13 +107,13 @@ TEST(record_still_rejects_other_control_characters) { snprintf(msg.username, sizeof(msg.username), "alice"); snprintf(msg.content, sizeof(msg.content), "esc\033[2Jhere"); - assert(message_log_format_record(&msg, line, sizeof(line), NULL) < 0); + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) < 0); snprintf(msg.content, sizeof(msg.content), "del\177here"); - assert(message_log_format_record(&msg, line, sizeof(line), NULL) < 0); + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) < 0); snprintf(msg.content, sizeof(msg.content), "tab\there"); - assert(message_log_format_record(&msg, line, sizeof(line), NULL) < 0); + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) < 0); } TEST(username_still_rejects_newline) { @@ -123,18 +123,138 @@ TEST(username_still_rejects_newline) { msg.timestamp = 1704067200; snprintf(msg.username, sizeof(msg.username), "a\nb"); snprintf(msg.content, sizeof(msg.content), "hi"); - assert(message_log_format_record(&msg, line, sizeof(line), NULL) < 0); + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) < 0); } TEST(header_is_recognised_and_is_not_a_record) { message_t out = {0}; - assert(message_log_is_header(MESSAGE_LOG_HEADER "\n")); - assert(!message_log_is_header("2024-01-01T00:00:00Z|a|b\n")); + assert(message_log_header_version(MESSAGE_LOG_HEADER "\n") == 3); + assert(message_log_header_version("#tnt-message-log v2\n") == 2); + assert(message_log_header_version("#tnt-message-log v2") == 2); + assert(message_log_header_version("#tnt-message-log v4\n") == 0); + assert(message_log_header_version("#tnt-message-log v22\n") == 0); + assert(message_log_header_version("2024-01-01T00:00:00Z|a|b|\n") == 0); assert(!message_log_parse_record(MESSAGE_LOG_HEADER "\n", &out, time(NULL))); } +TEST(records_carry_the_verified_flag) { + message_t msg = {0}; + message_t back = {0}; + char line[MESSAGE_LOG_MAX_LINE]; + + msg.timestamp = 1704067200; + snprintf(msg.username, sizeof(msg.username), "alice"); + snprintf(msg.content, sizeof(msg.content), "hi"); + + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) == + 0); + assert(strcmp(line, "2024-01-01T00:00:00Z|alice|hi|\n") == 0); + assert(message_log_parse_record(line, &back, msg.timestamp)); + assert(!back.verified); + + msg.verified = true; + assert(message_log_format_record(&msg, true, line, sizeof(line), NULL) == + 0); + assert(strcmp(line, "2024-01-01T00:00:00Z|alice|hi|v\n") == 0); + assert(message_log_parse_record(line, &back, msg.timestamp)); + assert(back.verified); + + /* Exec dump keeps the three-field form. */ + assert(message_log_format_record(&msg, false, line, sizeof(line), NULL) == + 0); + assert(strcmp(line, "2024-01-01T00:00:00Z|alice|hi\n") == 0); +} + +TEST(parser_ignores_unknown_flags_and_rejects_bad_ones) { + message_t out = {0}; + time_t now = 1704067200; + + assert(message_log_parse_record("2024-01-01T00:00:00Z|a|b|xvz\n", &out, + now)); + assert(out.verified); + assert(message_log_parse_record("2024-01-01T00:00:00Z|a|b|xz\n", &out, + now)); + assert(!out.verified); + assert(!message_log_parse_record("2024-01-01T00:00:00Z|a|b|V\n", &out, + now)); + assert(!message_log_parse_record("2024-01-01T00:00:00Z|a|b|v \n", &out, + now)); + assert(!message_log_parse_record("2024-01-01T00:00:00Z|a|b\n", &out, + now)); + assert(!message_log_parse_record("2024-01-01T00:00:00Z|a|b|v|\n", &out, + now)); + assert(!message_log_parse_record("2024-01-01T00:00:00Z|a||v\n", &out, + now)); +} + +TEST(upgrade_adds_an_empty_flags_field) { + char out[MESSAGE_LOG_MAX_LINE]; + + assert(message_log_upgrade_record("2024-01-01T00:00:00Z|a|C:\\new\n", 2, + out, sizeof(out))); + assert(strcmp(out, "2024-01-01T00:00:00Z|a|C:\\new|\n") == 0); + assert(message_log_upgrade_record("2024-01-01T00:00:00Z|a|C:\\new\n", 1, + out, sizeof(out))); + assert(strcmp(out, "2024-01-01T00:00:00Z|a|C:\\\\new|\n") == 0); + assert(message_log_upgrade_record("2024-01-01T00:00:00Z|a|b\r\n", 2, out, + sizeof(out))); + assert(strcmp(out, "2024-01-01T00:00:00Z|a|b|\r\n") == 0); + /* An unterminated last line stays unterminated. */ + assert(message_log_upgrade_record("2024-01-01T00:00:00Z|a|b", 2, out, + sizeof(out))); + assert(strcmp(out, "2024-01-01T00:00:00Z|a|b|") == 0); + assert(message_log_upgrade_record("not a record\n", 2, out, sizeof(out))); + assert(strcmp(out, "not a record\n") == 0); + assert(!message_log_upgrade_record("x|y|z\n", 3, out, sizeof(out))); +} + +TEST(migration_from_v2_adds_flags_and_is_idempotent) { + char dir[] = "/tmp/tnt-migrate-XXXXXX"; + char path[512]; + char backup[512]; + char seed[512]; + char first[512]; + char second[512]; + char saved[512]; + char record[128]; + message_t out = {0}; + const char *start; + + assert(mkdtemp(dir) != NULL); + snprintf(path, sizeof(path), "%s/messages.log", dir); + snprintf(backup, sizeof(backup), "%s/messages.log.v2.bak", dir); + + snprintf(seed, sizeof(seed), "#tnt-message-log v2\n" + "2024-01-01T00:00:00Z|alice|C:\\\\new\n" + "2024-01-01T00:00:01Z|bob|plain\n"); + write_file(path, seed); + + assert(message_log_migrate(path) == 0); + read_file(path, first, sizeof(first)); + assert(strcmp(first, MESSAGE_LOG_HEADER "\n" + "2024-01-01T00:00:00Z|alice|C:\\\\new|\n" + "2024-01-01T00:00:01Z|bob|plain|\n") == 0); + read_file(backup, saved, sizeof(saved)); + assert(strcmp(saved, seed) == 0); + + start = strchr(first, '\n') + 1; + snprintf(record, sizeof(record), "%.*s", + (int)(strchr(start, '\n') - start + 1), start); + assert(message_log_parse_record(record, &out, 1704067200)); + assert(strcmp(out.content, "C:\\new") == 0); + assert(!out.verified); + + assert(message_log_migrate(path) == 0); + read_file(path, second, sizeof(second)); + assert(strcmp(first, second) == 0); + + unlink(path); + unlink(backup); + rmdir(dir); +} + TEST(a_v1_record_with_a_backslash_survives_migration) { /* Migration escapes the backslash, so the decoded text still matches the * original. This is the property that makes migration lossless. */ @@ -172,8 +292,8 @@ TEST(migration_escapes_backslashes_and_is_idempotent) { assert(message_log_migrate(path) == 0); read_file(path, first, sizeof(first)); assert(strncmp(first, MESSAGE_LOG_HEADER, strlen(MESSAGE_LOG_HEADER)) == 0); - assert(strstr(first, "|alice|C:\\\\new\n") != NULL); - assert(strstr(first, "|bob|plain\n") != NULL); + assert(strstr(first, "|alice|C:\\\\new|\n") != NULL); + assert(strstr(first, "|bob|plain|\n") != NULL); assert(file_exists(backup)); /* A second run must not escape the escape. */ @@ -191,7 +311,7 @@ TEST(migration_preserves_what_a_record_means) { message_t out = {0}; char line[MESSAGE_LOG_MAX_LINE]; - snprintf(line, sizeof(line), "2024-01-01T00:00:00Z|alice|C:\\\\new\n"); + snprintf(line, sizeof(line), "2024-01-01T00:00:00Z|alice|C:\\\\new|\n"); assert(message_log_parse_record(line, &out, 1704067200)); assert(strcmp(out.content, "C:\\new") == 0); } @@ -229,7 +349,7 @@ TEST(migration_keeps_a_line_it_cannot_represent_out_of_the_log) { assert(message_log_migrate(path) == 0); read_file(path, migrated, sizeof(migrated)); assert(strstr(migrated, "|alice|") == NULL); - assert(strstr(migrated, "|bob|kept\n") != NULL); + assert(strstr(migrated, "|bob|kept|\n") != NULL); assert(file_exists(backup)); unlink(path); @@ -247,6 +367,10 @@ int main(void) { RUN_TEST(record_still_rejects_other_control_characters); RUN_TEST(username_still_rejects_newline); RUN_TEST(header_is_recognised_and_is_not_a_record); + RUN_TEST(records_carry_the_verified_flag); + RUN_TEST(parser_ignores_unknown_flags_and_rejects_bad_ones); + RUN_TEST(upgrade_adds_an_empty_flags_field); + RUN_TEST(migration_from_v2_adds_flags_and_is_idempotent); RUN_TEST(a_v1_record_with_a_backslash_survives_migration); RUN_TEST(encoded_length_counts_the_escapes); RUN_TEST(migration_escapes_backslashes_and_is_idempotent); diff --git a/tests/unit/test_module_protocol.c b/tests/unit/test_module_protocol.c index 7eec655..08701e7 100644 --- a/tests/unit/test_module_protocol.c +++ b/tests/unit/test_module_protocol.c @@ -36,7 +36,7 @@ TEST(appends_message_created_jsonl_with_escaping) { snprintf(msg.content, sizeof(msg.content), "%s", "hello \"core\""); assert(tnt_module_append_message_created(out, sizeof(out), &pos, - "local-1", &msg, false) == 0); + "local-1", &msg) == 0); assert(strstr(out, "\"type\":\"message.created\"") != NULL); assert(strstr(out, "\"id\":\"local-1\"") != NULL); assert(strstr(out, "\"timestamp\":\"1970-01-01T00:00:00Z\"") != NULL); @@ -56,8 +56,9 @@ TEST(appends_verified_metadata) { snprintf(msg.username, sizeof(msg.username), "%s", "alice"); snprintf(msg.content, sizeof(msg.content), "%s", "hi"); + msg.verified = true; assert(tnt_module_append_message_created(out, sizeof(out), &pos, - "local-2", &msg, true) == 0); + "local-2", &msg) == 0); assert(strstr(out, "\"metadata\":{\"verified\":true}}}\n") != NULL); } @@ -202,7 +203,8 @@ TEST(parse_message_post_accepts_valid_sender) { TEST(parse_message_post_rejects_invalid_sender) { static const char *const senders[] = { "", "system", "module:echo", "*", "-dash", ".dot", "a|b", "a;b", - "abcdefghijklmnopqrstu", "bad\\u0001", "a:b", "module:", ":lead" + "abcdefghijklmnopqrstu", "bad\\u0001", "a:b", "module:", ":lead", + "alice \\u2713", "alice✔", "☑", "\\u2705bob" }; tnt_module_message_post_t post; @@ -341,6 +343,7 @@ TEST(parse_presence_remote_rejects_invalid) { "{\"type\":\"presence.remote\",\"nicknames\":[\"system\"]}", "{\"type\":\"presence.remote\",\"nicknames\":[\"a:b\"]}", "{\"type\":\"presence.remote\",\"nicknames\":[\"a|b\"]}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"bob ✓\"]}", "{\"type\":\"presence.remote\"," "\"nicknames\":[\"abcdefghijklmnopqrstu\"]}", "{\"type\":\"presence.snapshot\",\"nicknames\":[\"alice\"]}", @@ -365,6 +368,9 @@ TEST(parse_private_post) { assert(!tnt_module_parse_private_post( "{\"type\":\"private.post\",\"from\":\"system\",\"to\":\"bob\"," "\"plain_text\":\"hi\"}", &post)); + assert(!tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"alice\u2713\",\"to\":\"bob\"," + "\"plain_text\":\"hi\"}", &post)); assert(!tnt_module_parse_private_post( "{\"type\":\"private.post\",\"from\":\"alice\"," "\"plain_text\":\"hi\"}", &post)); diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c index b2bfe51..2145c97 100644 --- a/tests/unit/test_module_runtime.c +++ b/tests/unit/test_module_runtime.c @@ -53,6 +53,11 @@ bool input_identity_notice(const char *session, const char *plain_text) { return false; } +int input_identity_reserve(const char *handle) { + (void)handle; + return 0; +} + bool input_identity_verified(const char *session, const char *handle) { (void)session; (void)handle; @@ -590,7 +595,7 @@ TEST(presence_saturation_logs_outside_the_notifier) { assert(tnt_module_runtime_init() == 0); assert(dup2(log_fd, STDERR_FILENO) >= 0); - tnt_module_runtime_publish_message_created(&msg, false); + tnt_module_runtime_publish_message_created(&msg); sleep_millis(200); for (int i = 0; i < TNT_MODULE_QUEUE_LIMIT + 72; i++) { snprintf(nickname, sizeof(nickname), "user%d", i); diff --git a/tests/unit/test_theme.c b/tests/unit/test_theme.c index c64bb56..ccddbff 100644 --- a/tests/unit/test_theme.c +++ b/tests/unit/test_theme.c @@ -34,6 +34,8 @@ TEST(every_theme_has_complete_slots) { assert(t->accent_bold && t->accent_bold[0] != '\0'); assert(t->accent_dim && t->accent_dim[0] != '\0'); assert(t->accent_italic && t->accent_italic[0] != '\0'); + assert(t->verified && strcmp(t->verified, t->accent) != 0 && + strcmp(t->verified, t->accent_bold) != 0); } } diff --git a/tnt-chat.7 b/tnt-chat.7 index 98de2d8..fe78028 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -1,4 +1,4 @@ -.TH TNT-CHAT 7 "2026-10-14" "TNT 1.6.0" +.TH TNT-CHAT 7 "2026-10-14" "TNT 1.7.0" .SH NAME tnt-chat \- use the TNT interactive terminal interface .SH DESCRIPTION @@ -30,7 +30,15 @@ or the first free .BR anonymous2 , .BR anonymous3 , and so on when that is taken. -Nicknames are limited to 20 Unicode characters. +Nicknames are limited to 20 Unicode characters +and may not contain +.BR ✓ , +.BR ✔ , +.BR ☑ , +or +.BR ✅ , +which would pass for the verified mark described in +.BR "SIGNING IN" . They must be unique among active sessions without regard to ASCII letter case. The names @@ -57,8 +65,14 @@ The module shows what to do next, usually a link and a code. Once it confirms the member, the session is renamed to the handle and shows .BI "Signed in as @" handle . -A session that was using that name without signing in is renamed to -.BR anonymous . +A session that was using that name, or one that looks like it, without +signing in is renamed to +.BR anonymous , +or to the first free +.BR anonymous \fIN\fR, +and the room sees the rename. +The same happens when the identity module reserves a handle for a member +who signed in elsewhere. Several sessions may be signed in to the same handle at once. .P TNT recognises a member by SSH key only when the SSH login name is their @@ -82,9 +96,47 @@ unbinds the key, and the session becomes .B anonymous again. .P -Nobody else can take a reserved handle, at the nickname prompt or with +Nobody else can take a reserved handle, or a name that looks like one, at +the nickname prompt, with .BR :nick , -without regard to ASCII letter case. +or as the login name of +.BR "exec post" . +Two names look alike when they are equal after TNT drops invisible +characters, combining accents, +.BR _ , +.BR \- , +.BR . , +and spaces, reads fullwidth letters and common Cyrillic, Greek, and Latin +look-alikes as the ASCII letters they resemble, ignores ASCII letter case, +reads +.BR 0 , +.BR 1 , +.BR i , +and +.B | +as +.BR o , +.BR l , +.BR l , +and +.BR l , +and reads +.B rn +as +.B m +and +.B vv +as +.BR w . +So +.BR m1ngsama , +.BR MINGSAMA , +.BR m1ng_sama , +and +.B m1ngsarna +all look like +.BR mingsama . +m1ngsama ID applies the same rule when a member picks a handle. The prompt falls back to .BR anonymous , or to the first free @@ -93,9 +145,30 @@ or to the first free and so on, and both say: .P .EX -@alice belongs to a signed-in member. +@alice is taken by a signed-in member, or looks like their name. Use :login if it is you, or pick another name. .EE +.P +Messages a signed-in member sends, public text and +.B /me +actions alike, show +.B ✓ +after the sender's name, live and in history. +The mark has its own colour, never the theme's accent, and is bold in the +.B mono +theme. +.BR :users , +.BR :last , +and +.B :search +mark signed-in members the same way. +Since no nickname may contain a check mark, a name followed by +.B ✓ +was signed in when it spoke. +TNT records the mark in +.I messages.log +when the message is sent; see +.BR tnt\-message\-log (5). .SH KEYS Typing inserts at the caret. Nothing a user types moves the session into a different keyboard state. @@ -366,6 +439,8 @@ The following aliases have identical behavior. .TP .B :users, :list, :who List active nicknames and connection durations. +Signed-in members are marked +.BR ✓ . Users connected through an app follow, marked .BR app . The online count in the status bar includes them. diff --git a/tnt-exec.7 b/tnt-exec.7 index e8f788a..f60d259 100644 --- a/tnt-exec.7 +++ b/tnt-exec.7 @@ -1,4 +1,4 @@ -.TH TNT-EXEC 7 "2026-10-14" "TNT 1.6.0" +.TH TNT-EXEC 7 "2026-10-14" "TNT 1.7.0" .SH NAME tnt-exec \- TNT SSH exec command and output protocol .SH DESCRIPTION @@ -115,6 +115,8 @@ Each message is one tab-separated line: The three fields are an RFC 3339 UTC timestamp, sender, and content. .B tail does not read the complete persistent log. +It does not say whether a sender was verified; the output keeps the three +fields it has always had, so existing scripts keep working. .SS dump .SY dump .RI [ count ] @@ -128,8 +130,14 @@ does not read the complete persistent log. .YS Write valid records from the active persistent message log in chronological order. -The output is the exact format defined by -.BR tnt\-message\-log (5). +Each record is a +.BR tnt\-message\-log (5) +record without its flags field: three fields and two separators, with the +content escaped, as in log version 2. +No marker line is written. +Read +.I messages.log +itself to see which senders were verified. .P Without an argument, .B dump diff --git a/tnt-message-log.5 b/tnt-message-log.5 index 0c5b780..2613a3d 100644 --- a/tnt-message-log.5 +++ b/tnt-message-log.5 @@ -1,27 +1,31 @@ -.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.6.0" +.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.7.0" .SH NAME tnt-message-log \- TNT public message log format .SH DESCRIPTION TNT stores public room history in an append-only UTF-8 file named .IR messages.log . -This page defines version 2 of the record format used for replay, search, +This page defines version 3 of the record format used for replay, search, export, validation, and recovery. .P -A version 2 file begins with the marker line -.BR "#tnt-message-log v2" . -A file without it is version 1 and is migrated in place at server start. +A version 3 file begins with the marker line +.BR "#tnt-message-log v3" . +A file that begins with +.B "#tnt-message-log v2" +is version 2, and a file with no marker line is version 1. +The server migrates both in place at start. See .B VERSIONS below. .SH FORMAT Each record occupies one newline-terminated line. -It has exactly three fields and two literal +It has exactly four fields and three literal .B | separators: .P .EX -#tnt\-message\-log v2 -2026-05-27T12:34:56Z|alice|hello +#tnt\-message\-log v3 +2026-05-27T12:34:56Z|alice|hello|v +2026-05-27T12:35:02Z|bob|hi alice| .EE .P The fields are: @@ -35,6 +39,22 @@ A nonempty UTF-8 string of at most 63 bytes. .TP .I content A nonempty UTF-8 string of at most 1023 bytes, escaped as described below. +.TP +.I flags +Zero or more lowercase ASCII letters, each a fact about the record. +The only flag defined so far is +.BR v : +the sender proved its identity when the message was sent. +That is a message from a signed-in interactive session, an exec +.B post +under the sender's own handle with its bound SSH key, or a +.B message.post +from the module holding +.BR identity:write . +For an action the sender is the actor named at the start of the content. +Readers ignore letters they do not know, so a later version may add flags +without making older records or older readers wrong. +Any other byte makes the record invalid. .P Neither string field may contain .BR | , @@ -62,13 +82,20 @@ Readers that present message text unescape these two sequences. Exec .B dump and the recovery modes emit the escaped form, one record per line. +Exec +.B dump +leaves out the flags field, as it did before version 3; see +.BR tnt\-exec (7). .SH VALIDATION One parser is used by server replay, interactive search, exec .BR dump , offline checking, and recovery. A record is invalid if it: .IP \(bu 2 -does not have exactly two separators and three nonempty fields; +does not have exactly three separators, with nonempty timestamp, username, +and content fields; +.IP \(bu 2 +has a flags field with anything but lowercase ASCII letters; .IP \(bu 2 does not end with a newline; .IP \(bu 2 @@ -89,7 +116,7 @@ Normal readers skip invalid records and continue with later records. A truncated final append is therefore ignored rather than replayed. .SH WRITING The server writes the marker line when it creates the file, so a new -installation is version 2 from its first message. +installation is version 3 from its first message. .P Before appending a record, the server replaces .BR | , @@ -101,6 +128,9 @@ Interactive composition and paste can produce a line feed; .B exec post and module-created messages cannot, because both refuse every C0 byte. Timestamps are written in UTC. +The flags field is +.B v +for a verified sender and empty otherwise. .P Interactive public messages, public room notices, exec posts, and module-created messages use this log. @@ -128,8 +158,10 @@ Neither mode starts the SSH server. Validate every physical record and write a summary to standard output. .TP .BI "tnt \-\-log\-recover " file -Write the marker line and each valid record in canonical version 2 form to +Write the marker line and each valid record in canonical version 3 form to standard output. +Canonical flags are the ones this version knows, so an unknown flag is +dropped. Write the summary to standard error. The source file is not modified. .P @@ -137,7 +169,7 @@ The summary has these line-oriented fields: .P .EX path /var/lib/tnt/messages.log -format_version 2 +format_version 3 records_seen 120 valid_records 119 invalid_records 1 @@ -153,8 +185,9 @@ Both modes return 0 when all records are valid. They return 1 when invalid records or I/O errors occur. .P -Given a version 1 file, both modes escape each content field before -validating it, so recovery output means what the original meant. +Given a version 1 or 2 file, both modes upgrade each record as migration +does before validating it, so recovery output means what the original +meant. .SH FILES .TP .I state-directory/messages.log @@ -164,39 +197,54 @@ Active public message log. Single rotated predecessor of the active log. .TP .I state-directory/messages.log.v1.bak -Copy of the log as it stood before migration. +Copy of a version 1 log as it stood before migration. +.TP +.I state-directory/messages.log.v2.bak +Copy of a version 2 log as it stood before migration. .SH VERSIONS -The three-field, control-free UTF-8 framing contract is stable throughout +The control-free UTF-8 framing, one record per line, is stable throughout TNT 1.x. -Version 2 adds the marker line and the content encoding above. +Version 2 (TNT 1.3.2) adds the marker line and the content encoding above. +Version 3 (TNT 1.7.0) adds the flags field, so whether a sender was verified +is recorded when the message is sent rather than guessed later from who +holds a handle now. .P -At start, the server migrates a log that has no marker line. +At start, the server migrates an older log. It copies the file to -.IR messages.log.v1.bak , -writes a replacement containing the marker line followed by every record with -its backslashes escaped, and renames that over the original. -A record whose escaped content would exceed the field limit is left in the -backup only. -.P -Migration is idempotent: a file that already carries the marker line is not -touched, so a restart, a restore, or a copy cannot escape an escaped file. -Escaping is not reversible by a second pass, which is why the marker travels -inside the file rather than beside it. -.P -The migration exists because a version 1 record could already contain a -backslash, as in +.I messages.log.v1.bak +or +.IR messages.log.v2.bak , +writes a replacement containing the version 3 marker line followed by every +record upgraded, and renames that over the original. +Upgrading a version 1 record escapes its backslashes; upgrading any record +appends an empty flags field, so no older message is marked verified. +A version 1 record whose escaped content would exceed the field limit is +left in the backup only. +.P +Migration is idempotent: a file that already carries the version 3 marker +line is not touched, so a restart, a restore, or a copy cannot upgrade a +record twice. +Escaping is not reversible by a second pass, which is why the marker +travels inside the file rather than beside it. +.P +Version 1 migration exists because a version 1 record could already contain +a backslash, as in .BR C:\enew . Read under version 2 rules without escaping, that content would decode to .B C: followed by a line feed and .BR ew . .P -A TNT 1.3.x binary reads a version 2 log without dropping records. -It skips the marker line as unparseable and shows -.B \en -and -.B \e\e -literally. +TNT 1.6 and earlier read a version 3 log as having no valid records, +because every record has one separator too many for them. +TNT 1.3.2 through 1.6 also take the version 3 marker for a version 1 log +and migrate it again. +To go back to an older release, stop the server and restore +.IR messages.log.v2.bak , +which lacks every message sent since the upgrade. +The rotated +.I messages.log.1 +is not migrated and may still be version 2. .SH CAVEATS The file is append-only operational state, not an editable configuration file. diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index af3561c..ff501e4 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -1,4 +1,4 @@ -.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.6.0" +.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.7.0" .SH NAME tnt-module-protocol \- implement an external TNT message module .SH DESCRIPTION @@ -296,8 +296,10 @@ event: .P .B sender must be a valid interactive nickname: at most 20 characters, no leading -space, dot, or hyphen, no control characters, no colon, and none of -.BR "| ; & $ ` < > ( ) { } [ ] ' \(dq \e" . +space, dot, or hyphen, no control characters, no colon, none of +.BR "| ; & $ ` < > ( ) { } [ ] ' \(dq \e" , +and none of the check marks +.BR "✓ ✔ ☑ ✅" . It must also not be .BR * , .BR system , @@ -306,7 +308,8 @@ or its Chinese equivalent; the colon restriction already excludes the form TNT reserves for its own messages. Unless the module also holds .BR identity:write , -it must not be a reserved handle either; see +it must not be a reserved handle, or a name that looks like one, either; +see .BR identity:write . .B plain_text follows the @@ -434,8 +437,11 @@ Sign the session in as TNT reserves the handle, binds the SSH key the session authenticated with, if any, to it and to no other handle, renames the session, and announces the rename to the room. -Another session using the name without signing in is renamed to -.BR anonymous . +Another session using the name, or a name that looks like it, without +signing in is renamed to +.BR anonymous , +or the first free +.BR anonymous \fIN\fR. TNT ignores a session that is unknown or has closed. .TP .B identity.reserve @@ -446,6 +452,16 @@ TNT ignores a session that is unknown or has closed. Reserve .B handle without binding a key, for a member who signed in elsewhere. +Every session using the name, or a name that looks like it, without signing +in is renamed to +.BR anonymous , +or the first free +.BR anonymous \fIN\fR, +and the room sees the rename. +Write this record when someone joins under a registered handle, or a name +that looks like one, to move them off it at once. +.BR tnt\-chat (7) +says when two names look alike. .P An identity record with a missing or invalid member, or an invalid handle, is a protocol failure like any other invalid response. @@ -514,7 +530,7 @@ rules. Unless the module also holds .BR identity:write , .B from -must not be a reserved handle. +must not be a reserved handle or a name that looks like one. TNT ignores a .B to that no session uses. @@ -634,14 +650,15 @@ modules. A module holding .B message:post can post under any valid nickname, including one an SSH user is using, -except a reserved handle. +except a reserved handle or a name that looks like one. A module holding .B identity:write can post under, and sign any session in as, any handle. A module holding .B message:private reads every private message SSH users send to remote users and can send -private messages under any nickname that is not a reserved handle. +private messages under any nickname that neither is nor looks like a +reserved handle. Grant any of them only to a module you trust as much as the server. .P The child inherits most of the server environment. diff --git a/tnt.8 b/tnt.8 index 636db51..b951df8 100644 --- a/tnt.8 +++ b/tnt.8 @@ -1,4 +1,4 @@ -.TH TNT 8 "2026-10-14" "TNT 1.6.0" +.TH TNT 8 "2026-10-14" "TNT 1.7.0" .SH NAME tnt \- run the TNT SSH chat server .SH SYNOPSIS @@ -314,6 +314,7 @@ See .TP .I messages.log.1 The previous log produced by automatic rotation. +It is not migrated when the active log is, so it may be in an older format. .TP .I identities Handles reserved by signed-in members, created with mode 0600 and replaced @@ -330,6 +331,8 @@ alice SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng A handle is 1 through 20 ASCII lowercase letters, digits, and underscores; invalid lines are skipped. TNT rereads the file when it changes. +A nickname that looks like a handle here is reserved along with it; see +.BR tnt\-chat (7). .TP .I motd.txt Optional text displayed before the chat screen for each new interactive diff --git a/tntctl.1 b/tntctl.1 index 7eae595..76b2388 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -1,4 +1,4 @@ -.TH TNTCTL 1 "2026-10-14" "TNT 1.6.0" +.TH TNTCTL 1 "2026-10-14" "TNT 1.7.0" .SH NAME tntctl \- run TNT exec commands through SSH .SH SYNOPSIS