diff --git a/Makefile b/Makefile index 390d447..148eef6 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info +.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test module-runtime-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info all: $(TARGETS) @@ -123,6 +123,7 @@ test-advisory: all unit-test @cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)" @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)" @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)" + @cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh || echo "(module runtime tests are advisory)" unit-test: @echo "Running unit tests..." @@ -145,8 +146,13 @@ integration-test: all @cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh @cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh @cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh + @cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh @cd tests && ./test_tntctl_cli.sh +module-runtime-test: all + @echo "Running module runtime tests..." + @cd tests && PORT=$${PORT:-2222} ./test_module_runtime.sh + anonymous-access-test: all @echo "Running anonymous access tests..." @cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh diff --git a/README.md b/README.md index 6761620..1a140f8 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,10 @@ TNT/ │ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── chat_room.c # chat room logic │ ├── message.c # message persistence +│ ├── module_protocol.c # external module JSONL protocol helpers +│ ├── module_runtime.c # optional external module supervisor +│ ├── json_text.c # small JSON string helpers +│ ├── input_buffer.c # validated terminal input buffer helpers │ ├── history_view.c # message viewport and scroll state │ ├── help_text.c # full-screen key reference content │ ├── manual.c # concise manual panel rendering @@ -428,7 +432,11 @@ tnt.service - systemd service unit ``` The persisted chat-history format is documented in -[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). +[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). Experimental community modules +should follow the external-process protocol in +[docs/MODULE_PROTOCOL.md](docs/MODULE_PROTOCOL.md). Module-generated content +must always include a plain-text fallback so TNT can keep working on basic +terminal clients and preserve the stable `messages.log` v1 history contract. ### MOTD (Message of the Day) @@ -450,6 +458,7 @@ Delete `motd.txt` to disable the MOTD. - [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide - [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages - [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields +- [Module Protocol](docs/MODULE_PROTOCOL.md) - External-process module contract - [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference - [Contributing](docs/CONTRIBUTING.md) - How to contribute - [Changelog](docs/CHANGELOG.md) - Version history diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8a61866..1c5bdc7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -89,6 +89,37 @@ Recommended interpretation: - `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds - `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits +## Edge Module Production Profile + +Some deployments intentionally track the newest TNT builds and newest module +integrations to exercise the full product surface. Treat these as edge +production environments: user-facing, but optimized for fast integration and +fast rollback. + +For that profile: + +- Deploy TNT and modules as separate artifacts so a module can be disabled + without replacing the core server. +- Keep module permissions explicit and minimal. Do not grant private-message + access unless the module exists for that purpose. +- Keep a known-good TNT binary and module manifest set on disk for immediate + rollback. +- Log module startup failures, invalid JSONL, protocol errors, and timeouts + separately from chat history. +- Prefer plain-text fallbacks for every module-created message, even when the + module also targets richer terminal renderers. +- Before promoting a module, test its manifest and JSONL handshake against the + protocol in `docs/MODULE_PROTOCOL.md`. + +Enable modules explicitly with `TNT_MODULE_PATHS`, using a colon-separated +list of module directories: + +```bash +TNT_MODULE_PATHS=/opt/tnt-modules/echo-module:/opt/tnt-modules/other-module +``` + +Unset `TNT_MODULE_PATHS` and restart TNT to return to the plain core server. + ## MOTD (Message of the Day) Place a `motd.txt` file in the state directory. TNT displays it to each user on connect; they press any key to enter the chat. diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 84492e2..9467bc3 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -80,6 +80,10 @@ src/ ├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── exec_catalog.c - SSH exec command matching and help metadata ├── exec.c - SSH exec command dispatch +├── json_text.c - JSON string escaping and top-level string extraction +├── input_buffer.c - Validated INSERT/COMMAND/paste buffer helpers +├── module_protocol.c - External module JSONL protocol helpers +├── module_runtime.c - Optional external module supervisor ├── tntctl.c - Local wrapper around the SSH exec interface ├── tntctl_text.c - tntctl local help and diagnostics ├── chat_room.c - Chat room state, message ring, and update sequence @@ -112,6 +116,10 @@ include/ ├── message_log_tool.h - Offline log check/recover interface ├── command_catalog.h - COMMAND-mode command metadata interface ├── exec_catalog.h - SSH exec command metadata interface +├── json_text.h - JSON text helper interface +├── input_buffer.h - Terminal input buffer helper interface +├── module_protocol.h - External module protocol helper interface +├── module_runtime.h - External module supervisor interface ├── cli_text.h - Server CLI text interface ├── tntctl_text.h - tntctl text interface ├── history_view.h - Scroll-state helpers diff --git a/docs/MODULE_PROTOCOL.md b/docs/MODULE_PROTOCOL.md new file mode 100644 index 0000000..f94baa5 --- /dev/null +++ b/docs/MODULE_PROTOCOL.md @@ -0,0 +1,143 @@ +# TNT Module Protocol + +This document defines the compatibility contract for external TNT modules. +The first implementation target is external-process modules that exchange +JSON Lines with TNT over stdin/stdout. Keeping modules out of the server +address space makes the community extension surface easier to audit, restart, +rate-limit, and disable. + +The protocol is intentionally separate from `messages.log` v1. TNT 1.x keeps +the persisted public history format stable. Module-generated content must +always provide a plain-text fallback that can be stored and rendered by older +or less capable clients. + +TNT core should stay conservative: text-first, terminal-compatible, and easy +to deploy over plain SSH. Modules are the extension surface for personalized +workflow features, rich rendering, terminal-specific visuals, and other +experience experiments. Integrating a module with TNT must not make plain +terminal users lose the basic chat path. + +## Compatibility + +- Protocol version: `tnt.module.v1` +- Transport: UTF-8 JSON Lines +- Framing: one complete JSON object per line +- Direction: TNT sends events to module stdin; modules write responses to + stdout +- Error stream: modules should write diagnostics to stderr +- License: module protocol examples and official community modules should use + the same license as TNT unless a module states stricter terms + +Modules are disabled unless `TNT_MODULE_PATHS` is set. The value is a +colon-separated list of module directories, each containing `tnt-module.json` +and the declared executable entrypoint. + +TNT may add optional fields to existing messages. Modules must ignore unknown +fields. TNT must ignore unknown response fields unless the response type +explicitly requires them. + +## Manifest + +Each module directory should include `tnt-module.json`: + +```json +{ + "protocol": "tnt.module.v1", + "name": "echo", + "version": "0.1.0", + "description": "Echoes public messages for testing", + "entrypoint": "./echo-module.sh", + "permissions": ["message:read", "message:create"], + "events": ["message.created"] +} +``` + +Required fields: + +- `protocol`: protocol compatibility string +- `name`: stable module id, lowercase ASCII, `a-z`, `0-9`, and `-` +- `version`: module version +- `entrypoint`: executable path relative to the manifest directory +- `permissions`: explicit capabilities requested by the module +- `events`: event names the module wants to receive + +## Handshake + +TNT starts a module process and writes a handshake event: + +```json +{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.0.1"}} +``` + +The module should answer: + +```json +{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo","version":"0.1.0"}} +``` + +If the module cannot run, it should answer: + +```json +{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v2"} +``` + +## Events + +Message-created event: + +```json +{ + "type": "message.created", + "message": { + "id": "local-00000001", + "timestamp": "2026-06-04T12:00:00Z", + "sender": "alice", + "kind": "text", + "plain_text": "hello", + "metadata": {} + } +} +``` + +The `plain_text` field is mandatory for every user-visible message. Future +rich content, images, and terminal-specific render hints must be represented +as optional metadata or attachment records with a plain-text fallback. + +## Responses + +Create a public message: + +```json +{"type":"message.create","plain_text":"echo: hello"} +``` + +No-op acknowledgement: + +```json +{"type":"event.ok"} +``` + +Module error: + +```json +{"type":"error","code":"bad_request","message":"missing plain_text"} +``` + +## Security Rules + +- Modules are untrusted external processes. +- TNT should enforce per-module permissions before delivering events or + accepting responses. +- TNT should cap stdout line length, startup time, event handling time, and + total queued output. +- TNT should disable a module after repeated invalid JSON, protocol errors, or + timeout failures. +- Modules must never receive private messages unless they request and are + granted an explicit private-message permission. + +## Rendering Rules + +Every module-created message must be renderable as plain text. Terminal image +protocols such as Kitty graphics or Sixel are optional renderer capabilities, +not message requirements. A module may provide attachment metadata later, but +TNT must be able to fall back to a link, filename, digest, or short label. diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 88b7ba3..4250806 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -78,9 +78,13 @@ STRUCTURE src/commands.c COMMAND-mode command dispatch src/exec_catalog.c SSH exec command matching, usage, argument shape src/exec.c SSH exec command dispatch + src/json_text.c JSON string escape/extract helpers + src/input_buffer.c validated INSERT/COMMAND/paste buffer helpers src/message.c persistence, search src/message_log.c messages.log v1 parsing and formatting src/message_log_tool.c offline messages.log check/recover CLI + src/module_protocol.c external module JSONL protocol helpers + src/module_runtime.c optional external module supervisor src/history_view.c message viewport / scroll state src/help_text.c full-screen key reference text src/manual.c concise manual panel rendering diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7060372..99e0ecb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,6 +80,26 @@ Goal: keep the interface efficient for terminal users without sacrificing simpli - ✅ improve discoverability of NORMAL and COMMAND mode actions - ✅ make status lines and help output concise enough for small terminals +## Stage 4.5: Module Foundation + +Goal: let community features plug into TNT without coupling every user request +to the core server binary. + +- keep TNT core basic and broadly compatible; route personalized workflows, + rich visuals, and terminal-specific experience upgrades through modules +- define the external-process module protocol before loading any third-party + code into production rooms +- keep module messages compatible with plain terminal clients by requiring + plain-text fallbacks for rich content and attachments +- treat terminal image protocols as optional renderer capabilities, not as the + core message format +- prefer JSON Lines over stdin/stdout for early modules so TNT can supervise, + restart, rate-limit, and disable modules independently +- keep module permissions explicit: message read/create, command registration, + private-message access, and future attachment access must be separate grants +- publish official examples in a companion community repository that tracks + TNT protocol versions and license terms + ## Stage 5: Operations and Security Goal: make public deployment manageable. diff --git a/include/input_buffer.h b/include/input_buffer.h new file mode 100644 index 0000000..3852d29 --- /dev/null +++ b/include/input_buffer.h @@ -0,0 +1,35 @@ +#ifndef INPUT_BUFFER_H +#define INPUT_BUFFER_H + +#include "common.h" + +typedef enum { + TNT_INPUT_APPEND_OK = 0, + TNT_INPUT_APPEND_IGNORED = 1 << 0, + TNT_INPUT_APPEND_OVERFLOW = 1 << 1, + TNT_INPUT_APPEND_INVALID_UTF8 = 1 << 2 +} tnt_input_append_status_t; + +typedef struct { + char bytes[4]; + int len; + int expected_len; +} tnt_input_utf8_state_t; + +void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state); + +int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b); +int tnt_input_append_utf8_sequence(char *input, size_t input_size, + const char *bytes, int len); + +/* Append one byte from a terminal stream, validating UTF-8 across calls. + * In paste mode CR/LF/TAB are normalized to spaces so existing TNT 1.x + * single-line message semantics are preserved. */ +int tnt_input_append_stream_byte(char *input, size_t input_size, + tnt_input_utf8_state_t *state, + unsigned char b, bool paste_mode); + +/* Returns TNT_INPUT_APPEND_INVALID_UTF8 when the stream ended mid-codepoint. */ +int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state); + +#endif /* INPUT_BUFFER_H */ diff --git a/include/json_text.h b/include/json_text.h new file mode 100644 index 0000000..f3697e4 --- /dev/null +++ b/include/json_text.h @@ -0,0 +1,15 @@ +#ifndef JSON_TEXT_H +#define JSON_TEXT_H + +#include "common.h" + +void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos, + const char *text); + +/* Extract a top-level JSON string field from a single JSON object. + * Returns false for malformed JSON, missing key, non-string value, or output + * overflow. Unknown nested objects and arrays are skipped. */ +bool tnt_json_get_string_field(const char *json, const char *key, + char *out, size_t out_size); + +#endif /* JSON_TEXT_H */ diff --git a/include/module_protocol.h b/include/module_protocol.h new file mode 100644 index 0000000..f50908b --- /dev/null +++ b/include/module_protocol.h @@ -0,0 +1,24 @@ +#ifndef MODULE_PROTOCOL_H +#define MODULE_PROTOCOL_H + +#include "message.h" + +#define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1" +#define TNT_MODULE_EVENT_MESSAGE_CREATED "message.created" +#define TNT_MODULE_RESPONSE_MESSAGE_CREATE "message.create" + +typedef struct { + char plain_text[MAX_MESSAGE_LEN]; +} tnt_module_message_create_t; + +int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, + const char *server_version); + +int tnt_module_append_message_created(char *buffer, size_t buf_size, + size_t *pos, const char *message_id, + const message_t *msg); + +bool tnt_module_parse_message_create(const char *line, + tnt_module_message_create_t *out); + +#endif /* MODULE_PROTOCOL_H */ diff --git a/include/module_runtime.h b/include/module_runtime.h new file mode 100644 index 0000000..401e4e2 --- /dev/null +++ b/include/module_runtime.h @@ -0,0 +1,27 @@ +#ifndef MODULE_RUNTIME_H +#define MODULE_RUNTIME_H + +#include "message.h" + +#define TNT_MAX_MODULES 8 +#define TNT_MODULE_QUEUE_LIMIT 128 + +typedef struct { + char name[64]; + char entrypoint[PATH_MAX]; + bool wants_message_created; + bool can_read_messages; + bool can_create_messages; +} tnt_module_manifest_t; + +int tnt_module_manifest_load(const char *module_dir, + tnt_module_manifest_t *out); + +int tnt_module_runtime_init(void); +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); + +#endif /* MODULE_RUNTIME_H */ diff --git a/src/exec.c b/src/exec.c index 845bfe2..2d0fdc0 100644 --- a/src/exec.c +++ b/src/exec.c @@ -5,7 +5,9 @@ #include "exec_catalog.h" #include "i18n.h" #include "input.h" +#include "json_text.h" #include "message.h" +#include "module_runtime.h" #include "ratelimit.h" #include "utf8.h" #include @@ -56,48 +58,6 @@ static void trim_ascii_whitespace(char *text) { } } -static void json_append_string(char *buffer, size_t buf_size, size_t *pos, - const char *text) { - const unsigned char *p = (const unsigned char *)(text ? text : ""); - - buffer_append_bytes(buffer, buf_size, pos, "\"", 1); - - while (*p && *pos < buf_size - 1) { - char escaped[7]; - - switch (*p) { - case '\\': - buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2); - break; - case '"': - buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2); - break; - case '\n': - buffer_append_bytes(buffer, buf_size, pos, "\\n", 2); - break; - case '\r': - buffer_append_bytes(buffer, buf_size, pos, "\\r", 2); - break; - case '\t': - buffer_append_bytes(buffer, buf_size, pos, "\\t", 2); - break; - default: - if (*p < 0x20) { - snprintf(escaped, sizeof(escaped), "\\u%04x", *p); - buffer_append_bytes(buffer, buf_size, pos, - escaped, strlen(escaped)); - } else { - buffer_append_bytes(buffer, buf_size, pos, - (const char *)p, 1); - } - break; - } - p++; - } - - buffer_append_bytes(buffer, buf_size, pos, "\"", 1); -} - static void resolve_exec_username(const client_t *client, char *buffer, size_t buf_size) { if (!buffer || buf_size == 0) { @@ -188,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) { if (i > 0) { buffer_append_bytes(output, output_size, &pos, ",", 1); } - json_append_string(output, output_size, &pos, usernames[i]); + tnt_json_append_string(output, output_size, &pos, usernames[i]); } buffer_append_bytes(output, output_size, &pos, "]\n", 2); } else { @@ -467,6 +427,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); if (client_send(client, "posted\n", 7) != 0) { return TNT_EXIT_ERROR; diff --git a/src/input.c b/src/input.c index 640c6c5..aa8b206 100644 --- a/src/input.c +++ b/src/input.c @@ -7,7 +7,9 @@ #include "exec.h" #include "history_view.h" #include "i18n.h" +#include "input_buffer.h" #include "message.h" +#include "module_runtime.h" #include "ratelimit.h" #include "system_message.h" #include "tui.h" @@ -196,24 +198,6 @@ static int read_channel_exact(client_t *client, char *buf, size_t len, return (int)got; } -static bool append_paste_byte(char *input, unsigned char b) { - if (b == '\r' || b == '\n' || b == '\t') { - b = ' '; - } - if (b < 32) { - return true; - } - - size_t cur = strlen(input); - if (cur < MAX_MESSAGE_LEN - 1) { - input[cur] = (char)b; - input[cur + 1] = '\0'; - return true; - } - - return false; -} - static int normal_visible_message_count(const client_t *client) { if (!client || !client->mute_joins) { return room_get_message_count(g_room); @@ -500,6 +484,8 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { * spaces so a multi-line paste stays a * single message instead of N sends. */ bool overflow = false; + bool invalid_utf8 = false; + tnt_input_utf8_state_t paste_utf8 = {0}; while (1) { char b; int k = ssh_channel_read_timeout( @@ -520,21 +506,40 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { * but keep printable bytes that * followed it. */ for (int i = 0; i < t; i++) { - if (!append_paste_byte( - input, - (unsigned char)tail[i])) { + int status = + tnt_input_append_stream_byte( + input, MAX_MESSAGE_LEN, + &paste_utf8, + (unsigned char)tail[i], + true); + if (status & + TNT_INPUT_APPEND_OVERFLOW) { overflow = true; } + if (status & + TNT_INPUT_APPEND_INVALID_UTF8) { + invalid_utf8 = true; + } } continue; } - if (!append_paste_byte(input, - (unsigned char)b)) { + int status = tnt_input_append_stream_byte( + input, MAX_MESSAGE_LEN, &paste_utf8, + (unsigned char)b, true); + if (status & TNT_INPUT_APPEND_OVERFLOW) { overflow = true; } + if (status & TNT_INPUT_APPEND_INVALID_UTF8) { + invalid_utf8 = true; + } + } + if (tnt_input_utf8_state_finish( + &paste_utf8) & + TNT_INPUT_APPEND_INVALID_UTF8) { + invalid_utf8 = true; } tui_render_input(client, input); - if (overflow) { + if (overflow || invalid_utf8) { client_send(client, "\a", 1); } } @@ -580,7 +585,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { } room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - message_save(&msg); + if (message_save(&msg) == 0) { + tnt_module_runtime_publish_message_created(&msg); + } else { + fprintf(stderr, "interactive: failed to persist message\n"); + } input[0] = '\0'; } tui_render_screen(client); @@ -1011,10 +1020,9 @@ main_loop: if (client->mode == MODE_INSERT && !client->show_help && client->command_output[0] == '\0') { if (b >= 32 && b < 127) { /* ASCII printable */ - int len = strlen(input); - if (len < MAX_MESSAGE_LEN - 1) { - input[len] = b; - input[len + 1] = '\0'; + int status = tnt_input_append_ascii(input, + MAX_MESSAGE_LEN, b); + if (status == TNT_INPUT_APPEND_OK) { tui_render_input(client, input); } else { client_send(client, "\a", 1); @@ -1038,10 +1046,9 @@ main_loop: /* Invalid UTF-8 sequence */ continue; } - int len = strlen(input); - if (len + char_len <= MAX_MESSAGE_LEN - 1) { - memcpy(input + len, buf, char_len); - input[len + char_len] = '\0'; + int status = tnt_input_append_utf8_sequence( + input, MAX_MESSAGE_LEN, buf, char_len); + if (status == TNT_INPUT_APPEND_OK) { tui_render_input(client, input); } else { client_send(client, "\a", 1); @@ -1050,10 +1057,10 @@ main_loop: } else if (client->mode == MODE_COMMAND && !client->show_help && client->command_output[0] == '\0') { if (b >= 32 && b < 127) { /* ASCII printable */ - size_t len = strlen(client->command_input); - if (len < sizeof(client->command_input) - 1) { - client->command_input[len] = b; - client->command_input[len + 1] = '\0'; + int status = tnt_input_append_ascii( + client->command_input, sizeof(client->command_input), + b); + if (status == TNT_INPUT_APPEND_OK) { tui_render_command_input(client); } else { client_send(client, "\a", 1); @@ -1068,10 +1075,10 @@ main_loop: if (read_bytes != char_len - 1) continue; } if (!utf8_is_valid_sequence(buf, char_len)) continue; - size_t len = strlen(client->command_input); - if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { - memcpy(client->command_input + len, buf, char_len); - client->command_input[len + char_len] = '\0'; + int status = tnt_input_append_utf8_sequence( + client->command_input, sizeof(client->command_input), + buf, char_len); + if (status == TNT_INPUT_APPEND_OK) { tui_render_command_input(client); } else { client_send(client, "\a", 1); diff --git a/src/input_buffer.c b/src/input_buffer.c new file mode 100644 index 0000000..fff04d3 --- /dev/null +++ b/src/input_buffer.c @@ -0,0 +1,147 @@ +#include "input_buffer.h" + +#include "utf8.h" + +void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state) { + if (!state) return; + state->len = 0; + state->expected_len = 0; + memset(state->bytes, 0, sizeof(state->bytes)); +} + +static int append_bytes(char *input, size_t input_size, const char *bytes, + size_t len) { + size_t cur; + + if (!input || !bytes || input_size == 0 || len == 0) { + return TNT_INPUT_APPEND_IGNORED; + } + + cur = strlen(input); + if (cur + len >= input_size) { + return TNT_INPUT_APPEND_OVERFLOW; + } + + memcpy(input + cur, bytes, len); + input[cur + len] = '\0'; + return TNT_INPUT_APPEND_OK; +} + +int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b) { + char c = (char)b; + + if (b < 32 || b >= 127) { + return TNT_INPUT_APPEND_IGNORED; + } + + return append_bytes(input, input_size, &c, 1); +} + +int tnt_input_append_utf8_sequence(char *input, size_t input_size, + const char *bytes, int len) { + if (!bytes || len <= 0 || len > 4 || + !utf8_is_valid_sequence(bytes, len)) { + return TNT_INPUT_APPEND_INVALID_UTF8; + } + + return append_bytes(input, input_size, bytes, (size_t)len); +} + +static int append_printable_byte(char *input, size_t input_size, + tnt_input_utf8_state_t *state, + unsigned char b, bool paste_mode); + +static int start_utf8_sequence(char *input, size_t input_size, + tnt_input_utf8_state_t *state, + unsigned char b, bool paste_mode) { + int expected = utf8_byte_length(b); + + if (expected <= 1 || expected > 4) { + tnt_input_utf8_state_reset(state); + return TNT_INPUT_APPEND_INVALID_UTF8; + } + + state->bytes[0] = (char)b; + state->len = 1; + state->expected_len = expected; + + if (expected == 1) { + int status = tnt_input_append_utf8_sequence(input, input_size, + state->bytes, 1); + tnt_input_utf8_state_reset(state); + return status; + } + + (void)paste_mode; + return TNT_INPUT_APPEND_OK; +} + +static int append_printable_byte(char *input, size_t input_size, + tnt_input_utf8_state_t *state, + unsigned char b, bool paste_mode) { + int status = TNT_INPUT_APPEND_OK; + + if (b < 128) { + if (state && state->len > 0) { + tnt_input_utf8_state_reset(state); + status |= TNT_INPUT_APPEND_INVALID_UTF8; + } + status |= tnt_input_append_ascii(input, input_size, b); + return status; + } + + if (!state) { + return TNT_INPUT_APPEND_INVALID_UTF8; + } + + if (state->len == 0) { + return start_utf8_sequence(input, input_size, state, b, paste_mode); + } + + if ((b & 0xC0) != 0x80) { + tnt_input_utf8_state_reset(state); + status |= TNT_INPUT_APPEND_INVALID_UTF8; + status |= append_printable_byte(input, input_size, state, b, + paste_mode); + return status; + } + + state->bytes[state->len++] = (char)b; + if (state->len == state->expected_len) { + status |= tnt_input_append_utf8_sequence(input, input_size, + state->bytes, state->len); + tnt_input_utf8_state_reset(state); + } + + return status; +} + +int tnt_input_append_stream_byte(char *input, size_t input_size, + tnt_input_utf8_state_t *state, + unsigned char b, bool paste_mode) { + int status = TNT_INPUT_APPEND_OK; + + if (paste_mode && (b == '\r' || b == '\n' || b == '\t')) { + b = ' '; + } + + if (b < 32) { + if (state && state->len > 0) { + tnt_input_utf8_state_reset(state); + status |= TNT_INPUT_APPEND_INVALID_UTF8; + } + return status | TNT_INPUT_APPEND_IGNORED; + } + + return status | append_printable_byte(input, input_size, state, b, + paste_mode); +} + +int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state) { + if (!state || state->len == 0) { + return TNT_INPUT_APPEND_OK; + } + + tnt_input_utf8_state_reset(state); + return TNT_INPUT_APPEND_INVALID_UTF8; +} diff --git a/src/json_text.c b/src/json_text.c new file mode 100644 index 0000000..cd6bdf0 --- /dev/null +++ b/src/json_text.c @@ -0,0 +1,343 @@ +#include "json_text.h" + +#include + +void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos, + const char *text) { + const unsigned char *p = (const unsigned char *)(text ? text : ""); + + buffer_append_bytes(buffer, buf_size, pos, "\"", 1); + + while (*p && pos && *pos < buf_size - 1) { + char escaped[7]; + + switch (*p) { + case '\\': + buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2); + break; + case '"': + buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2); + break; + case '\b': + buffer_append_bytes(buffer, buf_size, pos, "\\b", 2); + break; + case '\f': + buffer_append_bytes(buffer, buf_size, pos, "\\f", 2); + break; + case '\n': + buffer_append_bytes(buffer, buf_size, pos, "\\n", 2); + break; + case '\r': + buffer_append_bytes(buffer, buf_size, pos, "\\r", 2); + break; + case '\t': + buffer_append_bytes(buffer, buf_size, pos, "\\t", 2); + break; + default: + if (*p < 0x20) { + snprintf(escaped, sizeof(escaped), "\\u%04x", *p); + buffer_append_bytes(buffer, buf_size, pos, + escaped, strlen(escaped)); + } else { + buffer_append_bytes(buffer, buf_size, pos, + (const char *)p, 1); + } + break; + } + p++; + } + + buffer_append_bytes(buffer, buf_size, pos, "\"", 1); +} + +static const char *skip_ws(const char *p) { + while (p && isspace((unsigned char)*p)) { + p++; + } + return p; +} + +static int hex_value(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static bool parse_hex4(const char *p, uint32_t *out) { + uint32_t cp = 0; + + if (!p || !out) return false; + + for (int i = 0; i < 4; i++) { + int v = hex_value(p[i]); + if (v < 0) return false; + cp = (cp << 4) | (uint32_t)v; + } + + *out = cp; + return true; +} + +static bool append_decoded_codepoint(char *out, size_t out_size, + size_t *pos, uint32_t cp) { + char bytes[4]; + size_t len; + + if (!out || !pos || out_size == 0 || cp == 0 || + cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + return false; + } + + if (cp <= 0x7F) { + bytes[0] = (char)cp; + len = 1; + } else if (cp <= 0x7FF) { + bytes[0] = (char)(0xC0 | (cp >> 6)); + bytes[1] = (char)(0x80 | (cp & 0x3F)); + len = 2; + } else if (cp <= 0xFFFF) { + bytes[0] = (char)(0xE0 | (cp >> 12)); + bytes[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + bytes[2] = (char)(0x80 | (cp & 0x3F)); + len = 3; + } else { + bytes[0] = (char)(0xF0 | (cp >> 18)); + bytes[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + bytes[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + bytes[3] = (char)(0x80 | (cp & 0x3F)); + len = 4; + } + + if (*pos + len >= out_size) { + return false; + } + + memcpy(out + *pos, bytes, len); + *pos += len; + out[*pos] = '\0'; + return true; +} + +static bool append_byte(char *out, size_t out_size, size_t *pos, char c) { + if (!out || !pos || out_size == 0 || *pos + 1 >= out_size) { + return false; + } + + out[(*pos)++] = c; + out[*pos] = '\0'; + return true; +} + +static bool parse_json_string(const char **cursor, char *out, + size_t out_size) { + const char *p; + size_t pos = 0; + + if (!cursor || !*cursor || **cursor != '"' || !out || out_size == 0) { + return false; + } + + out[0] = '\0'; + p = *cursor + 1; + + while (*p) { + unsigned char c = (unsigned char)*p++; + + if (c == '"') { + *cursor = p; + return true; + } + + if (c < 0x20) { + return false; + } + + if (c != '\\') { + if (!append_byte(out, out_size, &pos, (char)c)) { + return false; + } + continue; + } + + char esc = *p++; + switch (esc) { + case '"': + case '\\': + case '/': + if (!append_byte(out, out_size, &pos, esc)) return false; + break; + case 'b': + if (!append_byte(out, out_size, &pos, '\b')) return false; + break; + case 'f': + if (!append_byte(out, out_size, &pos, '\f')) return false; + break; + case 'n': + if (!append_byte(out, out_size, &pos, '\n')) return false; + break; + case 'r': + if (!append_byte(out, out_size, &pos, '\r')) return false; + break; + case 't': + if (!append_byte(out, out_size, &pos, '\t')) return false; + break; + case 'u': { + uint32_t cp; + if (!parse_hex4(p, &cp)) return false; + p += 4; + + if (cp >= 0xD800 && cp <= 0xDBFF) { + uint32_t low; + if (p[0] != '\\' || p[1] != 'u' || + !parse_hex4(p + 2, &low) || + low < 0xDC00 || low > 0xDFFF) { + return false; + } + p += 6; + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return false; + } + + if (!append_decoded_codepoint(out, out_size, &pos, cp)) { + return false; + } + break; + } + default: + return false; + } + } + + return false; +} + +static bool skip_json_string(const char **cursor) { + const char *p; + + if (!cursor || !*cursor || **cursor != '"') { + return false; + } + + p = *cursor + 1; + while (*p) { + unsigned char c = (unsigned char)*p++; + if (c == '"') { + *cursor = p; + return true; + } + if (c < 0x20) return false; + if (c == '\\') { + if (*p == 'u') { + uint32_t ignored; + if (!parse_hex4(p + 1, &ignored)) return false; + p += 5; + } else if (*p) { + p++; + } else { + return false; + } + } + } + + return false; +} + +static bool skip_json_value(const char **cursor) { + const char *p; + + if (!cursor || !*cursor) return false; + p = skip_ws(*cursor); + + if (*p == '"') { + if (!skip_json_string(&p)) return false; + *cursor = p; + return true; + } + + if (*p == '{' || *p == '[') { + int depth = 0; + do { + if (*p == '"' && !skip_json_string(&p)) { + return false; + } + if (*p == '{' || *p == '[') { + depth++; + p++; + } else if (*p == '}' || *p == ']') { + depth--; + p++; + } else if (*p) { + p++; + } else { + return false; + } + } while (depth > 0); + + *cursor = p; + return true; + } + + while (*p && *p != ',' && *p != '}' && *p != ']') { + p++; + } + + *cursor = p; + return true; +} + +bool tnt_json_get_string_field(const char *json, const char *key, + char *out, size_t out_size) { + const char *p; + bool found = false; + + if (!json || !key || key[0] == '\0' || !out || out_size == 0) { + return false; + } + + out[0] = '\0'; + p = skip_ws(json); + if (*p != '{') { + return false; + } + p++; + + while (1) { + char parsed_key[128]; + + p = skip_ws(p); + if (*p == '}') { + return false; + } + if (*p != '"' || !parse_json_string(&p, parsed_key, + sizeof(parsed_key))) { + return false; + } + + p = skip_ws(p); + if (*p != ':') { + return false; + } + p = skip_ws(p + 1); + + if (strcmp(parsed_key, key) == 0) { + if (*p != '"' || !parse_json_string(&p, out, out_size)) { + return false; + } + found = true; + } else if (!skip_json_value(&p)) { + return false; + } + + p = skip_ws(p); + if (*p == ',') { + p++; + continue; + } + if (*p == '}') { + return found; + } + return false; + } +} diff --git a/src/main.c b/src/main.c index c7a9e19..c2973d8 100644 --- a/src/main.c +++ b/src/main.c @@ -5,6 +5,7 @@ #include "i18n.h" #include "message.h" #include "message_log_tool.h" +#include "module_runtime.h" #include "ssh_server.h" #include #include @@ -238,17 +239,23 @@ int main(int argc, char **argv) { } message_init(); + if (tnt_module_runtime_init() < 0) { + fprintf(stderr, "Failed to initialize module runtime\n"); + return TNT_EXIT_ERROR; + } /* Create chat room */ g_room = room_create(); if (!g_room) { fprintf(stderr, "Failed to create chat room\n"); + tnt_module_runtime_shutdown(); return TNT_EXIT_ERROR; } /* Initialize server */ if (ssh_server_init(port) < 0) { fprintf(stderr, "Failed to initialize server\n"); + tnt_module_runtime_shutdown(); room_destroy(g_room); return TNT_EXIT_ERROR; } @@ -256,6 +263,7 @@ int main(int argc, char **argv) { /* Start server (blocking) */ int ret = ssh_server_start(0); + tnt_module_runtime_shutdown(); room_destroy(g_room); return ret; } diff --git a/src/module_protocol.c b/src/module_protocol.c new file mode 100644 index 0000000..10b227a --- /dev/null +++ b/src/module_protocol.c @@ -0,0 +1,112 @@ +#include "module_protocol.h" + +#include "common.h" +#include "json_text.h" +#include "message_log.h" +#include "utf8.h" + +static bool append_was_truncated(size_t pos, size_t buf_size) { + return buf_size == 0 || pos >= buf_size - 1; +} + +static bool has_plain_text_controls(const char *text) { + const unsigned char *p = (const unsigned char *)text; + + while (p && *p) { + if (*p < 32 || *p == 127) { + return true; + } + p++; + } + + return false; +} + +int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, + const char *server_version) { + const char *version = server_version ? server_version : TNT_VERSION; + size_t before; + + if (!buffer || !pos || buf_size == 0) { + return -1; + } + + before = *pos; + buffer_appendf(buffer, buf_size, pos, + "{\"type\":\"handshake\",\"protocol\":"); + tnt_json_append_string(buffer, buf_size, pos, TNT_MODULE_PROTOCOL_VERSION); + buffer_appendf(buffer, buf_size, pos, + ",\"server\":{\"name\":\"tnt\",\"version\":"); + tnt_json_append_string(buffer, buf_size, pos, version); + buffer_appendf(buffer, buf_size, pos, "}}\n"); + + return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 && + before != *pos + ? -1 + : 0; +} + +int tnt_module_append_message_created(char *buffer, size_t buf_size, + size_t *pos, const char *message_id, + const message_t *msg) { + char timestamp[64]; + size_t before; + + if (!buffer || !pos || buf_size == 0 || !message_id || !msg || + message_id[0] == '\0' || !utf8_is_valid_string(msg->username) || + !utf8_is_valid_string(msg->content)) { + return -1; + } + + before = *pos; + message_log_format_timestamp_utc(msg->timestamp, timestamp, + sizeof(timestamp)); + buffer_appendf(buffer, buf_size, pos, + "{\"type\":\"%s\",\"message\":{\"id\":", + TNT_MODULE_EVENT_MESSAGE_CREATED); + tnt_json_append_string(buffer, buf_size, pos, message_id); + buffer_appendf(buffer, buf_size, pos, ",\"timestamp\":"); + tnt_json_append_string(buffer, buf_size, pos, timestamp); + buffer_appendf(buffer, buf_size, pos, ",\"sender\":"); + tnt_json_append_string(buffer, buf_size, pos, msg->username); + buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\"," + "\"plain_text\":"); + tnt_json_append_string(buffer, buf_size, pos, msg->content); + buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n"); + + return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 && + before != *pos + ? -1 + : 0; +} + +bool tnt_module_parse_message_create(const char *line, + tnt_module_message_create_t *out) { + char type[64]; + char plain_text[MAX_MESSAGE_LEN]; + + if (!line || !out) { + return false; + } + + memset(out, 0, sizeof(*out)); + if (!tnt_json_get_string_field(line, "type", type, sizeof(type)) || + strcmp(type, TNT_MODULE_RESPONSE_MESSAGE_CREATE) != 0) { + return false; + } + + if (!tnt_json_get_string_field(line, "plain_text", plain_text, + sizeof(plain_text))) { + return false; + } + + if (plain_text[0] == '\0' || + strlen(plain_text) >= sizeof(out->plain_text) || + !utf8_is_valid_string(plain_text) || + has_plain_text_controls(plain_text)) { + return false; + } + + snprintf(out->plain_text, sizeof(out->plain_text), "%s", plain_text); + return true; +} diff --git a/src/module_runtime.c b/src/module_runtime.c new file mode 100644 index 0000000..445de70 --- /dev/null +++ b/src/module_runtime.c @@ -0,0 +1,537 @@ +#include "module_runtime.h" + +#include "chat_room.h" +#include "common.h" +#include "json_text.h" +#include "module_protocol.h" +#include "utf8.h" + +#include +#include +#include +#include +#include +#include + +#define TNT_MODULE_LINE_MAX 4096 +#define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000 +#define TNT_MODULE_RESPONSE_TIMEOUT_MS 100 + +struct client; +void notify_mentions(const char *content, const struct client *sender); + +typedef struct module_process { + tnt_module_manifest_t manifest; + pid_t pid; + int stdin_fd; + int stdout_fd; + bool active; +} module_process_t; + +typedef struct module_event_node { + message_t msg; + struct module_event_node *next; +} module_event_node_t; + +static module_process_t g_modules[TNT_MAX_MODULES]; +static int g_module_count = 0; +static pthread_t g_module_thread; +static bool g_thread_started = false; +static bool g_running = false; +static pthread_mutex_t g_queue_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_queue_cond = PTHREAD_COND_INITIALIZER; +static module_event_node_t *g_queue_head = NULL; +static module_event_node_t *g_queue_tail = NULL; +static int g_queue_len = 0; + +static bool is_safe_relative_entrypoint(const char *entrypoint) { + if (!entrypoint || entrypoint[0] == '\0' || entrypoint[0] == '/') { + return false; + } + if (strstr(entrypoint, "..") != NULL) { + return false; + } + for (const unsigned char *p = (const unsigned char *)entrypoint; *p; p++) { + if (*p <= 32 || *p == 127 || *p == '|' || *p == ';' || + *p == '&' || *p == '`' || *p == '$' || *p == '<' || + *p == '>' || *p == '\\') { + return false; + } + } + return true; +} + +static bool json_array_contains_string(const char *json, const char *key, + const char *value) { + char needle[128]; + const char *p; + + if (!json || !key || !value || + snprintf(needle, sizeof(needle), "\"%s\"", key) >= + (int)sizeof(needle)) { + return false; + } + + p = strstr(json, needle); + if (!p) return false; + p = strchr(p + strlen(needle), ':'); + if (!p) return false; + p++; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; + if (*p != '[') return false; + p++; + + while (*p) { + char item[128]; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' || + *p == ',') { + p++; + } + if (*p == ']') return false; + if (*p != '"') return false; + const char *cursor = p; + size_t pos = 0; + item[0] = '\0'; + cursor++; + while (*cursor && *cursor != '"') { + if (*cursor == '\\') { + cursor++; + if (!*cursor) return false; + } + if (pos + 1 >= sizeof(item)) return false; + item[pos++] = *cursor++; + } + if (*cursor != '"') return false; + item[pos] = '\0'; + if (strcmp(item, value) == 0) return true; + p = cursor + 1; + } + + return false; +} + +int tnt_module_manifest_load(const char *module_dir, + tnt_module_manifest_t *out) { + char manifest_path[PATH_MAX]; + char manifest[TNT_MODULE_LINE_MAX]; + char protocol[64]; + FILE *fp; + size_t n; + + if (!module_dir || module_dir[0] == '\0' || !out) { + return -1; + } + + memset(out, 0, sizeof(*out)); + if (snprintf(manifest_path, sizeof(manifest_path), "%s/tnt-module.json", + module_dir) >= (int)sizeof(manifest_path)) { + return -1; + } + + fp = fopen(manifest_path, "rb"); + if (!fp) { + return -1; + } + n = fread(manifest, 1, sizeof(manifest) - 1, fp); + fclose(fp); + manifest[n] = '\0'; + + if (n == 0 || n >= sizeof(manifest) - 1 || + !tnt_json_get_string_field(manifest, "protocol", protocol, + sizeof(protocol)) || + strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) != 0 || + !tnt_json_get_string_field(manifest, "name", out->name, + sizeof(out->name)) || + !tnt_json_get_string_field(manifest, "entrypoint", out->entrypoint, + sizeof(out->entrypoint)) || + !is_valid_username(out->name) || + !is_safe_relative_entrypoint(out->entrypoint)) { + return -1; + } + + out->wants_message_created = json_array_contains_string( + manifest, "events", TNT_MODULE_EVENT_MESSAGE_CREATED); + out->can_read_messages = json_array_contains_string( + manifest, "permissions", "message:read"); + out->can_create_messages = json_array_contains_string( + manifest, "permissions", "message:create"); + + if (!out->wants_message_created || !out->can_read_messages || + !out->can_create_messages) { + return -1; + } + + return 0; +} + +static int wait_fd_readable(int fd, int timeout_ms) { + fd_set readfds; + struct timeval tv; + + FD_ZERO(&readfds); + FD_SET(fd, &readfds); + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + + return select(fd + 1, &readfds, NULL, NULL, &tv); +} + +static int read_line_timeout(int fd, char *line, size_t line_size, + int timeout_ms) { + size_t pos = 0; + + if (!line || line_size == 0) return -1; + line[0] = '\0'; + + while (pos + 1 < line_size) { + char c; + int ready = wait_fd_readable(fd, timeout_ms); + if (ready <= 0) { + break; + } + ssize_t n = read(fd, &c, 1); + if (n <= 0) { + return -1; + } + if (c == '\n') { + line[pos] = '\0'; + return (int)pos; + } + if ((unsigned char)c < 32 && c != '\t' && c != '\r') { + return -1; + } + line[pos++] = c; + } + + line[pos] = '\0'; + return pos > 0 ? (int)pos : 0; +} + +static void close_module_process(module_process_t *module) { + if (!module || !module->active) return; + + close(module->stdin_fd); + close(module->stdout_fd); + kill(module->pid, SIGTERM); + waitpid(module->pid, NULL, WNOHANG); + module->active = false; +} + +static bool handshake_ok(const char *line) { + char type[64]; + char protocol[64]; + + return tnt_json_get_string_field(line, "type", type, sizeof(type)) && + strcmp(type, "handshake.ok") == 0 && + tnt_json_get_string_field(line, "protocol", protocol, + sizeof(protocol)) && + strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) == 0; +} + +static int start_module_process(const char *module_dir, + module_process_t *module) { + int in_pipe[2] = {-1, -1}; + int out_pipe[2] = {-1, -1}; + char handshake[512] = ""; + char line[TNT_MODULE_LINE_MAX]; + size_t pos = 0; + + if (!module) { + return -1; + } + if (pipe(in_pipe) < 0) { + return -1; + } + if (pipe(out_pipe) < 0) { + close(in_pipe[0]); + close(in_pipe[1]); + return -1; + } + + pid_t pid = fork(); + if (pid < 0) { + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); + return -1; + } + + if (pid == 0) { + close(in_pipe[1]); + close(out_pipe[0]); + if (dup2(in_pipe[0], STDIN_FILENO) < 0 || + dup2(out_pipe[1], STDOUT_FILENO) < 0 || + chdir(module_dir) < 0) { + _exit(127); + } + close(in_pipe[0]); + close(out_pipe[1]); + execl(module->manifest.entrypoint, module->manifest.entrypoint, + (char *)NULL); + _exit(127); + } + + close(in_pipe[0]); + close(out_pipe[1]); + + module->pid = pid; + module->stdin_fd = in_pipe[1]; + module->stdout_fd = out_pipe[0]; + module->active = true; + + if (tnt_module_append_handshake(handshake, sizeof(handshake), &pos, + TNT_VERSION) < 0 || + write(module->stdin_fd, handshake, strlen(handshake)) != + (ssize_t)strlen(handshake) || + read_line_timeout(module->stdout_fd, line, sizeof(line), + TNT_MODULE_HANDSHAKE_TIMEOUT_MS) <= 0 || + !handshake_ok(line)) { + close_module_process(module); + return -1; + } + + return 0; +} + +static void enqueue_message(const message_t *msg) { + module_event_node_t *node; + + if (!msg) return; + + pthread_mutex_lock(&g_queue_lock); + if (!g_running || g_queue_len >= TNT_MODULE_QUEUE_LIMIT) { + pthread_mutex_unlock(&g_queue_lock); + if (g_queue_len >= TNT_MODULE_QUEUE_LIMIT) { + fprintf(stderr, "module runtime: event queue full, dropping\n"); + } + return; + } + + node = calloc(1, sizeof(*node)); + if (!node) { + pthread_mutex_unlock(&g_queue_lock); + return; + } + node->msg = *msg; + + if (g_queue_tail) { + g_queue_tail->next = node; + } else { + g_queue_head = node; + } + g_queue_tail = node; + g_queue_len++; + pthread_cond_signal(&g_queue_cond); + pthread_mutex_unlock(&g_queue_lock); +} + +static module_event_node_t *dequeue_message(void) { + module_event_node_t *node; + + pthread_mutex_lock(&g_queue_lock); + while (g_running && !g_queue_head) { + pthread_cond_wait(&g_queue_cond, &g_queue_lock); + } + node = g_queue_head; + if (node) { + g_queue_head = node->next; + if (!g_queue_head) g_queue_tail = NULL; + g_queue_len--; + } + pthread_mutex_unlock(&g_queue_lock); + return node; +} + +static void publish_module_message(const module_process_t *module, + const char *plain_text) { + message_t msg = { + .timestamp = time(NULL), + }; + + if (!module || !plain_text || plain_text[0] == '\0') return; + + snprintf(msg.username, sizeof(msg.username), "module:%s", + module->manifest.name); + snprintf(msg.content, sizeof(msg.content), "%s", plain_text); + + if (message_save(&msg) < 0) { + fprintf(stderr, "module runtime: failed to persist module message\n"); + return; + } + + room_broadcast(g_room, &msg); + notify_mentions(msg.content, NULL); +} + +static void handle_module_response(module_process_t *module, const char *line) { + tnt_module_message_create_t create; + char type[64]; + + if (!module || !line || line[0] == '\0') return; + + if (tnt_module_parse_message_create(line, &create)) { + publish_module_message(module, create.plain_text); + return; + } + if (tnt_json_get_string_field(line, "type", type, sizeof(type)) && + strcmp(type, "event.ok") == 0) { + return; + } + + fprintf(stderr, "module runtime: ignored invalid response from %s\n", + module->manifest.name); +} + +static void deliver_message_to_module(module_process_t *module, + const message_t *msg, + uint64_t event_id) { + char event[TNT_MODULE_LINE_MAX] = ""; + char line[TNT_MODULE_LINE_MAX]; + char message_id[64]; + size_t pos = 0; + + if (!module || !module->active || !msg) return; + + 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) < 0 || + write(module->stdin_fd, event, strlen(event)) != + (ssize_t)strlen(event)) { + fprintf(stderr, "module runtime: disabling %s after write failure\n", + module->manifest.name); + close_module_process(module); + return; + } + + while (1) { + int n = read_line_timeout(module->stdout_fd, line, sizeof(line), + TNT_MODULE_RESPONSE_TIMEOUT_MS); + if (n == 0) { + return; + } + if (n < 0) { + fprintf(stderr, "module runtime: disabling %s after read failure\n", + module->manifest.name); + close_module_process(module); + return; + } + handle_module_response(module, line); + } +} + +static void *module_worker_main(void *arg) { + uint64_t event_id = 0; + (void)arg; + + while (g_running) { + module_event_node_t *node = dequeue_message(); + if (!node) { + continue; + } + + event_id++; + for (int i = 0; i < g_module_count; i++) { + deliver_message_to_module(&g_modules[i], &node->msg, event_id); + } + free(node); + } + + return NULL; +} + +static int load_modules_from_env(void) { + const char *paths = getenv("TNT_MODULE_PATHS"); + char copy[4096]; + char *saveptr = NULL; + char *token; + + if (!paths || paths[0] == '\0') { + return 0; + } + if (strlen(paths) >= sizeof(copy)) { + fprintf(stderr, "module runtime: TNT_MODULE_PATHS too long\n"); + return -1; + } + + snprintf(copy, sizeof(copy), "%s", paths); + token = strtok_r(copy, ":", &saveptr); + while (token && g_module_count < TNT_MAX_MODULES) { + module_process_t *module = &g_modules[g_module_count]; + + memset(module, 0, sizeof(*module)); + module->stdin_fd = -1; + module->stdout_fd = -1; + if (tnt_module_manifest_load(token, &module->manifest) == 0 && + start_module_process(token, module) == 0) { + fprintf(stderr, "module runtime: enabled %s\n", + module->manifest.name); + g_module_count++; + } else { + fprintf(stderr, "module runtime: failed to enable module at %s\n", + token); + } + token = strtok_r(NULL, ":", &saveptr); + } + + return 0; +} + +int tnt_module_runtime_init(void) { + g_module_count = 0; + g_running = false; + + if (load_modules_from_env() < 0) { + return -1; + } + if (g_module_count == 0) { + return 0; + } + + g_running = true; + if (pthread_create(&g_module_thread, NULL, module_worker_main, NULL) != 0) { + g_running = false; + for (int i = 0; i < g_module_count; i++) { + close_module_process(&g_modules[i]); + } + g_module_count = 0; + return -1; + } + g_thread_started = true; + return 0; +} + +void tnt_module_runtime_shutdown(void) { + module_event_node_t *node; + + pthread_mutex_lock(&g_queue_lock); + g_running = false; + pthread_cond_broadcast(&g_queue_cond); + pthread_mutex_unlock(&g_queue_lock); + + if (g_thread_started) { + pthread_join(g_module_thread, NULL); + g_thread_started = false; + } + + while ((node = g_queue_head) != NULL) { + g_queue_head = node->next; + free(node); + } + g_queue_tail = NULL; + g_queue_len = 0; + + for (int i = 0; i < g_module_count; i++) { + close_module_process(&g_modules[i]); + } + g_module_count = 0; +} + +void tnt_module_runtime_publish_message_created(const message_t *msg) { + if (!msg || g_module_count == 0) { + return; + } + + enqueue_message(msg); +} diff --git a/tests/test_module_runtime.sh b/tests/test_module_runtime.sh new file mode 100755 index 0000000..bb2455f --- /dev/null +++ b/tests/test_module_runtime.sh @@ -0,0 +1,130 @@ +#!/bin/sh +# Module runtime regression tests for TNT. + +PORT=${PORT:-12352} +PASS=0 +FAIL=0 +BIN="../tnt" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-module-test.XXXXXX") +MODULE_DIR="$STATE_DIR/echo-module" +SERVER_PID="" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" + +mkdir -p "$MODULE_DIR" +cat >"$MODULE_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "echo-module", + "version": "0.1.0", + "entrypoint": "./echo-module.sh", + "permissions": ["message:read", "message:create"], + "events": ["message.created"] +} +JSON + +cat >"$MODULE_DIR/echo-module.sh" <<'SH' +#!/bin/sh +json_escape() { + printf '%s' "$1" | awk ' + BEGIN { ORS = "" } + { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); print } + ' +} +extract_string() { + key=$1 + line=$2 + printf '%s\n' "$line" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" +} +while IFS= read -r line; do + protocol=$(extract_string protocol "$line") + plain_text=$(extract_string plain_text "$line") + if printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"handshake"'; then + if [ "$protocol" = "tnt.module.v1" ]; then + printf '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo-module","version":"0.1.0"}}\n' + else + printf '{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v1"}\n' + fi + elif printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"message.created"' && [ -n "$plain_text" ]; then + escaped=$(json_escape "echo: $plain_text") + printf '{"type":"message.create","plain_text":"%s"}\n' "$escaped" + else + printf '{"type":"event.ok"}\n' + fi +done +SH +chmod +x "$MODULE_DIR/echo-module.sh" + +echo "=== TNT Module Runtime Tests ===" + +TNT_LANG=en TNT_RATE_LIMIT=0 TNT_MODULE_PATHS="$MODULE_DIR" \ + "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +HEALTH_OUTPUT="" +for _ in 1 2 3 4 5 6 7 8 9 10; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "x server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 + fi + HEALTH_OUTPUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + [ "$HEALTH_OUTPUT" = "ok" ] && break + sleep 1 +done + +if [ "$HEALTH_OUTPUT" = "ok" ]; then + echo "✓ server starts with module runtime" + PASS=$((PASS + 1)) +else + echo "x health failed: $HEALTH_OUTPUT" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +POST_OUTPUT=$(ssh $SSH_OPTS alice@localhost post "hello module" 2>/dev/null || true) +if [ "$POST_OUTPUT" = "posted" ]; then + echo "✓ post succeeds with module runtime" + PASS=$((PASS + 1)) +else + echo "x post failed: $POST_OUTPUT" + FAIL=$((FAIL + 1)) +fi + +FOUND=0 +for _ in 1 2 3 4 5; do + TAIL_OUTPUT=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true) + if printf '%s\n' "$TAIL_OUTPUT" | grep -q 'module:echo-module.*echo: hello module'; then + FOUND=1 + break + fi + sleep 1 +done + +if [ "$FOUND" -eq 1 ]; then + echo "✓ module response is persisted and visible" + PASS=$((PASS + 1)) +else + echo "x module response missing" + printf '%s\n' "$TAIL_OUTPUT" + sed -n '1,200p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 1e2d9ec..0c89302 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -11,6 +11,10 @@ endif # Source files UTF8_SRC = ../../src/utf8.c +INPUT_BUFFER_SRC = ../../src/input_buffer.c +JSON_TEXT_SRC = ../../src/json_text.c +MODULE_PROTOCOL_SRC = ../../src/module_protocol.c +MODULE_RUNTIME_SRC = ../../src/module_runtime.c MESSAGE_SRC = ../../src/message.c MESSAGE_LOG_SRC = ../../src/message_log.c COMMON_SRC = ../../src/common.c @@ -28,7 +32,7 @@ HELP_TEXT_SRC = ../../src/help_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c RATELIMIT_SRC = ../../src/ratelimit.c -TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults +TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults .PHONY: all clean run @@ -37,6 +41,18 @@ all: $(TESTS) test_utf8: test_utf8.c $(UTF8_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) +test_input_buffer: test_input_buffer.c $(INPUT_BUFFER_SRC) $(UTF8_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +test_json_text: test_json_text.c $(JSON_TEXT_SRC) $(COMMON_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) @@ -80,6 +96,18 @@ run: all @echo "=== Running UTF-8 Tests ===" ./test_utf8 @echo "" + @echo "=== Running Input Buffer Tests ===" + ./test_input_buffer + @echo "" + @echo "=== Running JSON Text Tests ===" + ./test_json_text + @echo "" + @echo "=== Running Module Protocol Tests ===" + ./test_module_protocol + @echo "" + @echo "=== Running Module Runtime Tests ===" + ./test_module_runtime + @echo "" @echo "=== Running Message Tests ===" ./test_message @echo "" diff --git a/tests/unit/test_input_buffer.c b/tests/unit/test_input_buffer.c new file mode 100644 index 0000000..69dbffa --- /dev/null +++ b/tests/unit/test_input_buffer.c @@ -0,0 +1,130 @@ +#include "../../include/input_buffer.h" + +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +static int tests_passed = 0; + +TEST(appends_ascii_until_capacity) { + char input[6] = ""; + + assert(tnt_input_append_ascii(input, sizeof(input), 'h') == + TNT_INPUT_APPEND_OK); + assert(tnt_input_append_ascii(input, sizeof(input), 'e') == + TNT_INPUT_APPEND_OK); + assert(tnt_input_append_ascii(input, sizeof(input), 'l') == + TNT_INPUT_APPEND_OK); + assert(tnt_input_append_ascii(input, sizeof(input), 'l') == + TNT_INPUT_APPEND_OK); + assert(tnt_input_append_ascii(input, sizeof(input), 'o') == + TNT_INPUT_APPEND_OK); + assert(strcmp(input, "hello") == 0); + assert(tnt_input_append_ascii(input, sizeof(input), '!') == + TNT_INPUT_APPEND_OVERFLOW); + assert(strcmp(input, "hello") == 0); +} + +TEST(rejects_ascii_control_bytes) { + char input[8] = "x"; + + assert(tnt_input_append_ascii(input, sizeof(input), '\n') == + TNT_INPUT_APPEND_IGNORED); + assert(strcmp(input, "x") == 0); +} + +TEST(appends_valid_utf8_sequence) { + char input[16] = "hi "; + + assert(tnt_input_append_utf8_sequence(input, sizeof(input), + "\xE4\xB8\xAD", 3) == + TNT_INPUT_APPEND_OK); + assert(strcmp(input, "hi \xE4\xB8\xAD") == 0); +} + +TEST(rejects_invalid_utf8_sequence) { + char input[16] = ""; + + assert(tnt_input_append_utf8_sequence(input, sizeof(input), + "\xC3\x28", 2) == + TNT_INPUT_APPEND_INVALID_UTF8); + assert(strcmp(input, "") == 0); +} + +TEST(paste_stream_normalizes_newlines_and_tabs) { + char input[32] = ""; + tnt_input_utf8_state_t state = {0}; + + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 'a', true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + '\n', true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + '\t', true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 'b', true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_utf8_state_finish(&state) == TNT_INPUT_APPEND_OK); + assert(strcmp(input, "a b") == 0); +} + +TEST(paste_stream_validates_multibyte_utf8) { + char input[32] = ""; + tnt_input_utf8_state_t state = {0}; + + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 0xE4, true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 0xB8, true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 0xAD, true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_utf8_state_finish(&state) == TNT_INPUT_APPEND_OK); + assert(strcmp(input, "\xE4\xB8\xAD") == 0); +} + +TEST(paste_stream_rejects_partial_utf8_at_end) { + char input[32] = ""; + tnt_input_utf8_state_t state = {0}; + + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 0xE4, true) == TNT_INPUT_APPEND_OK); + assert(tnt_input_utf8_state_finish(&state) == + TNT_INPUT_APPEND_INVALID_UTF8); + assert(strcmp(input, "") == 0); +} + +TEST(paste_stream_drops_invalid_utf8_and_keeps_following_text) { + char input[32] = ""; + tnt_input_utf8_state_t state = {0}; + int status; + + assert(tnt_input_append_stream_byte(input, sizeof(input), &state, + 0xE4, true) == TNT_INPUT_APPEND_OK); + status = tnt_input_append_stream_byte(input, sizeof(input), &state, + 'x', true); + assert((status & TNT_INPUT_APPEND_INVALID_UTF8) != 0); + assert(strcmp(input, "x") == 0); +} + +int main(void) { + printf("Running input buffer unit tests...\n\n"); + + RUN_TEST(appends_ascii_until_capacity); + RUN_TEST(rejects_ascii_control_bytes); + RUN_TEST(appends_valid_utf8_sequence); + RUN_TEST(rejects_invalid_utf8_sequence); + RUN_TEST(paste_stream_normalizes_newlines_and_tabs); + RUN_TEST(paste_stream_validates_multibyte_utf8); + RUN_TEST(paste_stream_rejects_partial_utf8_at_end); + RUN_TEST(paste_stream_drops_invalid_utf8_and_keeps_following_text); + + printf("\nAll %d input buffer tests passed.\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_json_text.c b/tests/unit/test_json_text.c new file mode 100644 index 0000000..6e2f49e --- /dev/null +++ b/tests/unit/test_json_text.c @@ -0,0 +1,95 @@ +#include "../../include/json_text.h" + +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +static int tests_passed = 0; + +TEST(append_string_escapes_json_controls) { + char out[128] = ""; + size_t pos = 0; + + tnt_json_append_string(out, sizeof(out), &pos, "a\"b\\c\n\t"); + assert(strcmp(out, "\"a\\\"b\\\\c\\n\\t\"") == 0); +} + +TEST(get_string_field_extracts_top_level_value) { + char value[64]; + + assert(tnt_json_get_string_field( + "{\"type\":\"message.create\",\"plain_text\":\"hello\"}", + "plain_text", value, sizeof(value))); + assert(strcmp(value, "hello") == 0); +} + +TEST(get_string_field_skips_nested_values) { + char value[64]; + + assert(tnt_json_get_string_field( + "{\"nested\":{\"plain_text\":\"wrong\"},\"plain_text\":\"right\"}", + "plain_text", value, sizeof(value))); + assert(strcmp(value, "right") == 0); +} + +TEST(get_string_field_decodes_escapes) { + char value[64]; + + assert(tnt_json_get_string_field( + "{\"plain_text\":\"line\\nquote:\\\" ok\"}", + "plain_text", value, sizeof(value))); + assert(strcmp(value, "line\nquote:\" ok") == 0); +} + +TEST(get_string_field_decodes_unicode_escape) { + char value[64]; + + assert(tnt_json_get_string_field( + "{\"plain_text\":\"hello \\u4e2d\"}", + "plain_text", value, sizeof(value))); + assert(strcmp(value, "hello \xE4\xB8\xAD") == 0); +} + +TEST(get_string_field_rejects_missing_and_malformed_values) { + char value[64]; + + assert(!tnt_json_get_string_field("{\"x\":\"y\"}", "plain_text", + value, sizeof(value))); + assert(!tnt_json_get_string_field("{\"plain_text\":123}", "plain_text", + value, sizeof(value))); + assert(!tnt_json_get_string_field("{\"plain_text\":\"unterminated}", + "plain_text", value, sizeof(value))); + assert(!tnt_json_get_string_field( + "{\"plain_text\":\"ok\",\"unterminated\":\"x}", + "plain_text", value, sizeof(value))); +} + +TEST(get_string_field_rejects_output_overflow) { + char value[4]; + + assert(!tnt_json_get_string_field("{\"plain_text\":\"abcd\"}", + "plain_text", value, sizeof(value))); +} + +int main(void) { + printf("Running JSON text unit tests...\n\n"); + + RUN_TEST(append_string_escapes_json_controls); + RUN_TEST(get_string_field_extracts_top_level_value); + RUN_TEST(get_string_field_skips_nested_values); + RUN_TEST(get_string_field_decodes_escapes); + RUN_TEST(get_string_field_decodes_unicode_escape); + RUN_TEST(get_string_field_rejects_missing_and_malformed_values); + RUN_TEST(get_string_field_rejects_output_overflow); + + printf("\nAll %d JSON text tests passed.\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_module_protocol.c b/tests/unit/test_module_protocol.c new file mode 100644 index 0000000..f06af99 --- /dev/null +++ b/tests/unit/test_module_protocol.c @@ -0,0 +1,120 @@ +#include "../../include/module_protocol.h" + +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +static int tests_passed = 0; + +TEST(appends_handshake_jsonl) { + char out[256] = ""; + size_t pos = 0; + + assert(tnt_module_append_handshake(out, sizeof(out), &pos, "9.9.9") == 0); + assert(strcmp(out, + "{\"type\":\"handshake\",\"protocol\":\"tnt.module.v1\"," + "\"server\":{\"name\":\"tnt\",\"version\":\"9.9.9\"}}\n") == + 0); +} + +TEST(appends_message_created_jsonl_with_escaping) { + char out[512] = ""; + size_t pos = 0; + message_t msg = { + .timestamp = 0, + }; + + snprintf(msg.username, sizeof(msg.username), "%s", "alice"); + snprintf(msg.content, sizeof(msg.content), "%s", "hello \"core\""); + + assert(tnt_module_append_message_created(out, sizeof(out), &pos, + "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); + assert(strstr(out, "\"sender\":\"alice\"") != NULL); + assert(strstr(out, "\"kind\":\"text\"") != NULL); + assert(strstr(out, "\"plain_text\":\"hello \\\"core\\\"\"") != NULL); + assert(out[strlen(out) - 1] == '\n'); +} + +TEST(parse_message_create_accepts_valid_plain_text) { + tnt_module_message_create_t response; + + assert(tnt_module_parse_message_create( + "{\"type\":\"message.create\",\"plain_text\":\"echo: hello\"}", + &response)); + assert(strcmp(response.plain_text, "echo: hello") == 0); +} + +TEST(parse_message_create_accepts_utf8_plain_text) { + tnt_module_message_create_t response; + + assert(tnt_module_parse_message_create( + "{\"type\":\"message.create\",\"plain_text\":\"echo: \\u4e2d\"}", + &response)); + assert(strcmp(response.plain_text, "echo: \xE4\xB8\xAD") == 0); +} + +TEST(parse_message_create_rejects_wrong_type) { + tnt_module_message_create_t response; + + assert(!tnt_module_parse_message_create( + "{\"type\":\"event.ok\",\"plain_text\":\"hello\"}", &response)); +} + +TEST(parse_message_create_rejects_empty_or_control_text) { + tnt_module_message_create_t response; + + assert(!tnt_module_parse_message_create( + "{\"type\":\"message.create\",\"plain_text\":\"\"}", &response)); + assert(!tnt_module_parse_message_create( + "{\"type\":\"message.create\",\"plain_text\":\"line\\nnext\"}", + &response)); +} + +TEST(parse_message_create_rejects_invalid_utf8_text) { + tnt_module_message_create_t response; + char line[] = "{\"type\":\"message.create\",\"plain_text\":\"bad \xC3\x28\"}"; + + assert(!tnt_module_parse_message_create(line, &response)); +} + +TEST(parse_message_create_rejects_overlong_text) { + char line[MAX_MESSAGE_LEN + 128]; + tnt_module_message_create_t response; + size_t pos = 0; + + buffer_appendf(line, sizeof(line), &pos, + "{\"type\":\"message.create\",\"plain_text\":\""); + for (int i = 0; i < MAX_MESSAGE_LEN; i++) { + buffer_append_bytes(line, sizeof(line), &pos, "a", 1); + } + buffer_appendf(line, sizeof(line), &pos, "\"}"); + + assert(!tnt_module_parse_message_create(line, &response)); +} + +int main(void) { + printf("Running module protocol unit tests...\n\n"); + + RUN_TEST(appends_handshake_jsonl); + RUN_TEST(appends_message_created_jsonl_with_escaping); + RUN_TEST(parse_message_create_accepts_valid_plain_text); + RUN_TEST(parse_message_create_accepts_utf8_plain_text); + RUN_TEST(parse_message_create_rejects_wrong_type); + RUN_TEST(parse_message_create_rejects_empty_or_control_text); + RUN_TEST(parse_message_create_rejects_invalid_utf8_text); + RUN_TEST(parse_message_create_rejects_overlong_text); + + printf("\nAll %d module protocol tests passed.\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c new file mode 100644 index 0000000..6ce93b8 --- /dev/null +++ b/tests/unit/test_module_runtime.c @@ -0,0 +1,155 @@ +#include "../../include/module_runtime.h" +#include "../../include/chat_room.h" + +#include +#include +#include +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +static int tests_passed = 0; +static char module_dir[PATH_MAX]; + +chat_room_t *g_room = NULL; + +void room_broadcast(chat_room_t *room, const message_t *msg) { + (void)room; + (void)msg; +} + +int message_save(const message_t *msg) { + (void)msg; + return 0; +} + +void notify_mentions(const char *content, const void *sender) { + (void)content; + (void)sender; +} + +static void cleanup_module_dir(void) { + if (module_dir[0] == '\0') return; + + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/tnt-module.json", module_dir); + unlink(path); + rmdir(module_dir); + module_dir[0] = '\0'; +} + +static void setup_module_dir(void) { + const char *tmp = getenv("TMPDIR"); + + cleanup_module_dir(); + if (!tmp || tmp[0] == '\0') tmp = "/tmp"; + snprintf(module_dir, sizeof(module_dir), "%s/tnt-module-test.XXXXXX", tmp); + assert(mkdtemp(module_dir) != NULL); +} + +static void write_manifest(const char *body) { + char path[PATH_MAX]; + FILE *fp; + + snprintf(path, sizeof(path), "%s/tnt-module.json", module_dir); + fp = fopen(path, "wb"); + assert(fp != NULL); + fputs(body, fp); + fclose(fp); +} + +TEST(loads_valid_manifest) { + tnt_module_manifest_t manifest; + + setup_module_dir(); + write_manifest( + "{" + "\"protocol\":\"tnt.module.v1\"," + "\"name\":\"echo-module\"," + "\"version\":\"0.1.0\"," + "\"entrypoint\":\"./echo-module.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]" + "}"); + + assert(tnt_module_manifest_load(module_dir, &manifest) == 0); + assert(strcmp(manifest.name, "echo-module") == 0); + assert(strcmp(manifest.entrypoint, "./echo-module.sh") == 0); + assert(manifest.wants_message_created); + assert(manifest.can_read_messages); + assert(manifest.can_create_messages); + cleanup_module_dir(); +} + +TEST(rejects_wrong_protocol) { + tnt_module_manifest_t manifest; + + setup_module_dir(); + write_manifest( + "{\"protocol\":\"tnt.module.v2\",\"name\":\"echo\"," + "\"entrypoint\":\"./echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + cleanup_module_dir(); +} + +TEST(rejects_missing_permissions_or_events) { + tnt_module_manifest_t manifest; + + setup_module_dir(); + write_manifest( + "{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\"," + "\"entrypoint\":\"./echo.sh\"," + "\"permissions\":[\"message:read\"]," + "\"events\":[\"message.created\"]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + + write_manifest( + "{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\"," + "\"entrypoint\":\"./echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + cleanup_module_dir(); +} + +TEST(rejects_unsafe_entrypoint) { + tnt_module_manifest_t manifest; + + setup_module_dir(); + write_manifest( + "{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\"," + "\"entrypoint\":\"../echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + + write_manifest( + "{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\"," + "\"entrypoint\":\"/tmp/echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + cleanup_module_dir(); +} + +int main(void) { + printf("Running module runtime unit tests...\n\n"); + + RUN_TEST(loads_valid_manifest); + RUN_TEST(rejects_wrong_protocol); + RUN_TEST(rejects_missing_permissions_or_events); + RUN_TEST(rejects_unsafe_entrypoint); + + printf("\nAll %d module runtime tests passed.\n", tests_passed); + return 0; +}