diff --git a/README.md b/README.md index 7d5f5d4..bd48bd6 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ ssh -p 2222 chat.example.com health ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com users ssh -p 2222 chat.example.com "tail -n 20" +ssh -p 2222 chat.example.com "dump -n 100" ssh -p 2222 operator@chat.example.com post "service notice" ssh -p 2222 chat.example.com post "/me deploys v2.0" ``` @@ -212,6 +213,7 @@ around the same SSH exec interface: ```sh tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index da8e83b..d010817 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ and JSON field shapes for package tests, scripts, and future `tntctl` work. - Documented `messages.log` v1 as the stable TNT 1.x persisted history format, including parser, sanitization, and partial-record recovery rules. +- Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for + exporting valid persisted `messages.log` v1 records. - Added a public security policy, supported-version guidance, and GitHub issue templates for bug reports and feature requests. - Added `tntctl`, a thin local wrapper around the documented SSH exec diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index de9b65a..6351a86 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -56,6 +56,7 @@ ssh -p 2222 chat.example.com health ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com users --json ssh -p 2222 chat.example.com "tail -n 20" +ssh -p 2222 chat.example.com "dump -n 100" ssh -p 2222 operator@chat.example.com post "service notice" ``` @@ -64,6 +65,7 @@ The same commands can be run through `tntctl`: ```sh tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" tntctl --host-key-checking accept-new chat.example.com users ``` @@ -128,6 +130,22 @@ Prints recent in-memory messages as tab-separated lines: The current upper bound is `MAX_MESSAGES`. This command reads the live in-memory room buffer, not the full persisted log. +### `dump [N]` / `dump -n N` + +Exports valid persisted `messages.log` v1 records in chronological order: + +```text +2026-05-25T12:00:00Z|alice|hello +``` + +Without `N`, `dump` exports all valid persisted records. With `N`, it exports +the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or +truncated records are skipped by the same strict parser used for replay and +search. + +This command reads the on-disk log, not the live in-memory room buffer. A +missing log produces empty output and exit status `0`. + ### `post MESSAGE` Posts a message as the SSH login name and prints: diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 4b7517f..b06fa9e 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -53,6 +53,13 @@ Replay and search use the same strict parser. TNT skips records that are: Skipping a bad record is intentional recovery behavior. A truncated final line is treated as a partial append and ignored rather than replayed. +## Export + +`dump [N]` and `dump -n N` export valid persisted records through the SSH exec +interface and `tntctl`. The output format is exactly the v1 record format +above. Without `N`, `dump` exports all valid records; with `N`, it exports the +last `N` valid records. + ## Compatibility The v1 record format is stable for TNT 1.x. Future incompatible storage diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index efcb65d..7bef386 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -46,6 +46,14 @@ INSERT MODE limit 1023 bytes/message; over-limit input rings bell normal opens/follows latest; k/PgUp older, j/PgDn newer +EXEC COMMANDS + health print service health + stats [--json] print room statistics + users [--json] list online users + tail [N] / tail -n N recent in-memory room messages + dump [N] / dump -n N persisted messages.log v1 records + post post as the SSH login name + STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2b03a25..91b0dac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,8 +57,8 @@ Goal: make stored history durable, inspectable, and recoverable. - ✅ formalize the message log v1 format - ✅ keep persisted timestamps in UTC throughout write and replay - ✅ validate persisted UTF-8 and record structure before replay/search +- ✅ provide an inspection/export command for persisted records - add log rotation and compaction tooling -- provide an offline inspection/export command - define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 43c9bd9..1f03184 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -14,7 +14,7 @@ The product path should stay short: 7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, - `stats`, `users`, `tail`, and `post`. + `stats`, `users`, `tail`, `dump`, and `post`. ## TUI Experience Notes diff --git a/include/exec_catalog.h b/include/exec_catalog.h index 69ae5ac..cc7d573 100644 --- a/include/exec_catalog.h +++ b/include/exec_catalog.h @@ -9,6 +9,7 @@ typedef enum { TNT_EXEC_COMMAND_USERS, TNT_EXEC_COMMAND_STATS, TNT_EXEC_COMMAND_TAIL, + TNT_EXEC_COMMAND_DUMP, TNT_EXEC_COMMAND_POST, TNT_EXEC_COMMAND_EXIT } tnt_exec_command_id_t; diff --git a/include/message.h b/include/message.h index 3da4800..0bf3803 100644 --- a/include/message.h +++ b/include/message.h @@ -26,4 +26,9 @@ 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 + * 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); + #endif /* MESSAGE_H */ diff --git a/src/exec.c b/src/exec.c index 524d70c..a1cccf7 100644 --- a/src/exec.c +++ b/src/exec.c @@ -291,6 +291,45 @@ static int parse_tail_count(const char *args, int *count) { return 0; } +static int parse_dump_count(const char *args, int *count) { + char *end = NULL; + long value; + + if (!count) { + return -1; + } + + *count = 0; + if (!args || args[0] == '\0') { + return 0; + } + + if (strncmp(args, "-n", 2) == 0) { + args += 2; + while (*args && isspace((unsigned char)*args)) { + args++; + } + } + + value = strtol(args, &end, 10); + if (end == args) { + return -1; + } + while (*end) { + if (!isspace((unsigned char)*end)) { + return -1; + } + end++; + } + + if (value < 1 || value > 10000) { + return -1; + } + + *count = (int)value; + return 0; +} + static int exec_command_tail(client_t *client, const char *args) { int requested = 20; int total_messages; @@ -347,6 +386,27 @@ static int exec_command_tail(client_t *client, const char *args) { return rc; } +static int exec_command_dump(client_t *client, const char *args) { + int requested = 0; + char *output = NULL; + size_t output_len = 0; + int rc; + + if (parse_dump_count(args, &requested) < 0) { + return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP); + } + + if (message_dump_text(&output, &output_len, requested) < 0) { + client_printf(client, "dump: failed to read message log\n"); + return TNT_EXIT_ERROR; + } + + rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK + : TNT_EXIT_ERROR; + free(output); + return rc; +} + static int exec_command_post(client_t *client, const char *args) { char content[MAX_MESSAGE_LEN]; char username[MAX_USERNAME_LEN]; @@ -451,6 +511,8 @@ int exec_dispatch(client_t *client) { return exec_command_stats(client, args != NULL); case TNT_EXEC_COMMAND_TAIL: return exec_command_tail(client, args); + case TNT_EXEC_COMMAND_DUMP: + return exec_command_dump(client, args); case TNT_EXEC_COMMAND_POST: return exec_command_post(client, args); case TNT_EXEC_COMMAND_EXIT: diff --git a/src/exec_catalog.c b/src/exec_catalog.c index 20bd420..d06e594 100644 --- a/src/exec_catalog.c +++ b/src/exec_catalog.c @@ -38,6 +38,14 @@ static const exec_catalog_entry_t entries[] = { "tail -n N", "tail [N] | tail -n N", I18N_STRING("Print recent messages", "输出最近消息"), false, false, false}, + {TNT_EXEC_COMMAND_DUMP, "dump", NULL, + "dump [N]", "dump [N] | dump -n N", + I18N_STRING("Export persisted messages", "导出持久化消息"), + false, false, false}, + {TNT_EXEC_COMMAND_DUMP, "dump", NULL, + "dump -n N", "dump [N] | dump -n N", + I18N_STRING("Export persisted messages", "导出持久化消息"), + false, false, false}, {TNT_EXEC_COMMAND_POST, "post", NULL, "post MESSAGE", "post MESSAGE", I18N_STRING("Post a message non-interactively", "非交互发送消息"), diff --git a/src/message.c b/src/message.c index 667795c..c71e6c1 100644 --- a/src/message.c +++ b/src/message.c @@ -6,6 +6,7 @@ #endif #include "message.h" #include "utf8.h" +#include #include #include @@ -26,6 +27,17 @@ static time_t parse_rfc3339_utc(const char *timestamp_str) { return timegm(&tm); } +static void format_rfc3339_utc(time_t ts, char *buffer, size_t buf_size) { + struct tm tm_info; + + if (!buffer || buf_size == 0) { + return; + } + + gmtime_r(&ts, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + static void discard_line_remainder(FILE *fp) { int c; @@ -102,6 +114,47 @@ static bool parse_log_record(const char *line, message_t *out, return true; } +static int append_dump_record(char **output, size_t *capacity, + size_t *len, const message_t *msg) { + char timestamp[64]; + int needed; + size_t available; + + if (!output || !capacity || !len || !msg) { + return -1; + } + + format_rfc3339_utc(msg->timestamp, timestamp, sizeof(timestamp)); + needed = snprintf(NULL, 0, "%s|%s|%s\n", timestamp, msg->username, + msg->content); + if (needed < 0) { + return -1; + } + + available = *capacity > *len ? *capacity - *len : 0; + if ((size_t)needed + 1 > available) { + size_t new_capacity = *capacity ? *capacity : 1024; + while ((size_t)needed + 1 > new_capacity - *len) { + if (new_capacity > SIZE_MAX / 2) { + return -1; + } + new_capacity *= 2; + } + + char *grown = realloc(*output, new_capacity); + if (!grown) { + return -1; + } + *output = grown; + *capacity = new_capacity; + } + + snprintf(*output + *len, *capacity - *len, "%s|%s|%s\n", timestamp, + msg->username, msg->content); + *len += (size_t)needed; + return 0; +} + /* Initialize message subsystem */ void message_init(void) { /* Nothing to initialize for now */ @@ -339,6 +392,118 @@ int message_search(const char *query, message_t **results, int max_results) { return (count < max_results) ? count : max_results; } +int message_dump_text(char **output, size_t *output_len, int max_records) { + char log_path[PATH_MAX]; + char *buf = NULL; + size_t capacity = 0; + size_t len = 0; + message_t *ring = NULL; + int seen = 0; + int rc = 0; + + if (!output || !output_len || max_records < 0) { + return -1; + } + + *output = calloc(1, 1); + if (!*output) { + return -1; + } + *output_len = 0; + + if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) { + free(*output); + *output = NULL; + return -1; + } + + if (max_records > 0) { + ring = calloc((size_t)max_records, sizeof(*ring)); + if (!ring) { + free(*output); + *output = NULL; + return -1; + } + } + + pthread_mutex_lock(&g_message_file_lock); + FILE *fp = fopen(log_path, "r"); + if (!fp) { + int saved_errno = errno; + pthread_mutex_unlock(&g_message_file_lock); + free(ring); + if (saved_errno != ENOENT) { + free(*output); + *output = NULL; + return -1; + } + return 0; + } + + char line[2048]; + time_t now = time(NULL); + while (fgets(line, sizeof(line), fp)) { + size_t line_len = strlen(line); + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); + continue; + } + + message_t parsed; + if (!parse_log_record(line, &parsed, now)) { + continue; + } + + if (max_records > 0) { + ring[seen % max_records] = parsed; + seen++; + } else if (append_dump_record(output, &capacity, output_len, + &parsed) < 0) { + rc = -1; + break; + } + } + + fclose(fp); + pthread_mutex_unlock(&g_message_file_lock); + + if (rc == 0 && max_records > 0 && seen > 0) { + int count = seen < max_records ? seen : max_records; + int start = seen < max_records ? 0 : seen % max_records; + + free(*output); + *output = NULL; + *output_len = 0; + + for (int i = 0; i < count; i++) { + message_t *msg = &ring[(start + i) % max_records]; + if (append_dump_record(&buf, &capacity, &len, msg) < 0) { + rc = -1; + break; + } + } + + if (rc == 0) { + *output = buf ? buf : calloc(1, 1); + *output_len = len; + if (!*output) { + rc = -1; + } + } else { + free(buf); + } + } + + free(ring); + if (rc < 0) { + free(*output); + *output = NULL; + *output_len = 0; + return -1; + } + return 0; +} + /* Format a message for display */ void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { struct tm tm_info; diff --git a/src/tntctl.c b/src/tntctl.c index 6e10349..9d21850 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -20,7 +20,7 @@ static void print_usage(FILE *stream) { " -h, --help Print this help and exit\n" "\n" "Commands mirror the TNT SSH exec interface: health, stats, users,\n" - "tail, post, help, and exit.\n"); + "tail, dump, post, help, and exit.\n"); } static bool is_valid_port(const char *value) { @@ -78,6 +78,7 @@ static bool is_known_exec_command(const char *command) { strcmp(command, "stats") == 0 || strcmp(command, "users") == 0 || strcmp(command, "tail") == 0 || + strcmp(command, "dump") == 0 || strcmp(command, "post") == 0 || strcmp(command, "help") == 0 || strcmp(command, "exit") == 0); diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index 7ce7109..fe92c34 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -140,6 +140,19 @@ else FAIL=$((FAIL + 1)) fi +DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null) +DUMP_USAGE_STATUS=$? +printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$' +if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then + echo "✓ dump usage follows TNT_LANG and exits 64" + PASS=$((PASS + 1)) +else + echo "✗ dump usage output unexpected" + printf '%s\n' "$DUMP_USAGE" + echo "exit status: $DUMP_USAGE_STATUS" + FAIL=$((FAIL + 1)) +fi + POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true) if [ "$POST_OUTPUT" = "posted" ]; then echo "✓ post publishes a message" @@ -161,6 +174,17 @@ else FAIL=$((FAIL + 1)) fi +DUMP_OUTPUT=$(ssh $SSH_OPTS localhost "dump -n 1" 2>/dev/null || true) +printf '%s\n' "$DUMP_OUTPUT" | grep -q '|execposter|hello from exec$' +if [ $? -eq 0 ]; then + echo "✓ dump returns persisted message log records" + PASS=$((PASS + 1)) +else + echo "✗ dump output unexpected" + printf '%s\n' "$DUMP_OUTPUT" + FAIL=$((FAIL + 1)) +fi + PERSIST_FAIL_MARKER="persist-fail-marker" rm -f "$STATE_DIR/messages.log" mkdir "$STATE_DIR/messages.log" @@ -261,6 +285,17 @@ else FAIL=$((FAIL + 1)) fi +TNTCTL_DUMP=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "dump" "-n" "1" 2>/dev/null || true) +printf '%s\n' "$TNTCTL_DUMP" | grep -q '|ctlposter|hello from tntctl$' +if [ $? -eq 0 ]; then + echo "✓ tntctl dump returns persisted message log records" + PASS=$((PASS + 1)) +else + echo "✗ tntctl dump output unexpected" + printf '%s\n' "$TNTCTL_DUMP" + FAIL=$((FAIL + 1)) +fi + EXPECT_SCRIPT="${STATE_DIR}/watcher.expect" WATCHER_READY="${STATE_DIR}/watcher.ready" cat >"$EXPECT_SCRIPT" </dev/null 2>&1 REMOTE_STATUS=$? if [ "$REMOTE_STATUS" -eq 64 ]; then diff --git a/tests/unit/test_exec_catalog.c b/tests/unit/test_exec_catalog.c index 480960e..c52bbbc 100644 --- a/tests/unit/test_exec_catalog.c +++ b/tests/unit/test_exec_catalog.c @@ -28,12 +28,14 @@ TEST(generates_localized_exec_help) { assert(strstr(en, "TNT exec interface") != NULL); assert(strstr(en, "Commands:") != NULL); assert(strstr(en, "users [--json]") != NULL); + assert(strstr(en, "dump [N]") != NULL); assert(strstr(en, "post MESSAGE") != NULL); assert(strstr(en, "support") == NULL); assert(strstr(zh, "TNT exec 接口") != NULL); assert(strstr(zh, "命令:") != NULL); assert(strstr(zh, "users [--json]") != NULL); + assert(strstr(zh, "dump [N]") != NULL); assert(strstr(zh, "post MESSAGE") != NULL); assert(strstr(zh, "support") == NULL); assert_ascii_angle_placeholders(zh); @@ -65,6 +67,10 @@ TEST(matches_exec_commands_and_args) { assert(id == TNT_EXEC_COMMAND_TAIL); assert(strcmp(args, "-n 20") == 0); + assert(exec_catalog_match("dump -n 20", &id, &args)); + assert(id == TNT_EXEC_COMMAND_DUMP); + assert(strcmp(args, "-n 20") == 0); + assert(exec_catalog_match("post hello world", &id, &args)); assert(id == TNT_EXEC_COMMAND_POST); assert(strcmp(args, "hello world") == 0); @@ -90,6 +96,9 @@ TEST(validates_argument_shapes) { assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL)); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20")); + assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, NULL)); + assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "-n 20")); + assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL)); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello")); } @@ -111,8 +120,8 @@ TEST(generates_localized_usage) { memset(en, 0, sizeof(en)); en_pos = 0; exec_catalog_append_usage(en, sizeof(en), &en_pos, - TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99); - assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0); + TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99); + assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0); } int main(void) { diff --git a/tests/unit/test_message.c b/tests/unit/test_message.c index 060fa0d..b4fd842 100644 --- a/tests/unit/test_message.c +++ b/tests/unit/test_message.c @@ -208,6 +208,53 @@ TEST(message_search_skips_malformed_records) { cleanup_state_dir(); } +TEST(message_dump_exports_valid_records) { + char ts[64]; + char log_path[PATH_MAX]; + char expected_all[512]; + char expected_last_two[512]; + char *dump = NULL; + size_t dump_len = 0; + + setup_state_dir(); + format_rfc3339_now(ts, sizeof(ts)); + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + + 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); + fclose(fp); + + snprintf(expected_all, sizeof(expected_all), + "%s|alice|first valid\n" + "%s|bob|second valid\n" + "%s|carol|third valid\n", + ts, ts, ts); + assert(message_dump_text(&dump, &dump_len, 0) == 0); + assert(dump != NULL); + assert(dump_len == strlen(expected_all)); + assert(strcmp(dump, expected_all) == 0); + free(dump); + + dump = NULL; + dump_len = 0; + snprintf(expected_last_two, sizeof(expected_last_two), + "%s|bob|second valid\n" + "%s|carol|third valid\n", + ts, ts); + assert(message_dump_text(&dump, &dump_len, 2) == 0); + assert(dump != NULL); + assert(dump_len == strlen(expected_last_two)); + assert(strcmp(dump, expected_last_two) == 0); + free(dump); + + cleanup_state_dir(); +} + /* Test edge cases */ TEST(message_edge_cases) { message_t msg; @@ -303,6 +350,7 @@ int main(void) { RUN_TEST(message_save_basic); RUN_TEST(message_load_skips_malformed_records); RUN_TEST(message_search_skips_malformed_records); + RUN_TEST(message_dump_exports_valid_records); RUN_TEST(message_edge_cases); RUN_TEST(message_special_characters); RUN_TEST(message_buffer_safety); diff --git a/tnt.1 b/tnt.1 index 85f879d..3d8af09 100644 --- a/tnt.1 +++ b/tnt.1 @@ -222,6 +222,7 @@ ssh host \-p 2222 help ssh host \-p 2222 users \-\-json ssh host \-p 2222 stats \-\-json ssh host \-p 2222 tail 20 +ssh host \-p 2222 dump \-n 100 ssh host \-p 2222 post "Hello from a script" ssh host \-p 2222 post "/me deploys v2.0" ssh host \-p 2222 health diff --git a/tntctl.1 b/tntctl.1 index 97a3c63..dc02696 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -73,6 +73,12 @@ Print recent messages. .B tail -n N Print recent messages. .TP +.B dump [N] +Export persisted messages. +.TP +.B dump -n N +Export persisted messages. +.TP .B post MESSAGE Post a message non-interactively. .TP @@ -82,6 +88,7 @@ Print the server exec help. .nf tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" tntctl --host-key-checking accept-new chat.example.com users .fi