Merge pull request #54 from m1ngsama/feat/module-foundation
Some checks failed
CI / PR gate (macos-latest) (push) Has been cancelled
CI / PR gate (ubuntu-24.04) (push) Has been cancelled
CI / Extended Linux runtime (push) Has been cancelled
CI / Portable build (alpine-musl) (push) Has been cancelled
CI / Portable build (debian-stable-glibc) (push) Has been cancelled
CI / Portable build (ubuntu-24.04-glibc) (push) Has been cancelled
CI / Package recipe gate (push) Has been cancelled

Add module foundation and optional runtime
This commit is contained in:
m1ngsama 2026-06-04 23:02:09 +08:00 committed by GitHub
commit 1284d5d052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2182 additions and 87 deletions

View file

@ -35,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) 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) 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} ./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} + 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} + 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: unit-test:
@echo "Running unit tests..." @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} + 3)) ./test_user_lifecycle.sh
@cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.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} + 5)) ./test_empty_view.sh
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh
@cd tests && ./test_tntctl_cli.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 anonymous-access-test: all
@echo "Running anonymous access tests..." @echo "Running anonymous access tests..."
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh @cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh

View file

@ -339,6 +339,10 @@ TNT/
│ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic │ ├── chat_room.c # chat room logic
│ ├── message.c # message persistence │ ├── 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 │ ├── history_view.c # message viewport and scroll state
│ ├── help_text.c # full-screen key reference content │ ├── help_text.c # full-screen key reference content
│ ├── manual.c # concise manual panel rendering │ ├── manual.c # concise manual panel rendering
@ -428,7 +432,11 @@ tnt.service - systemd service unit
``` ```
The persisted chat-history format is documented in 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) ### 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 - [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages - [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields - [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 - [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
- [Contributing](docs/CONTRIBUTING.md) - How to contribute - [Contributing](docs/CONTRIBUTING.md) - How to contribute
- [Changelog](docs/CHANGELOG.md) - Version history - [Changelog](docs/CHANGELOG.md) - Version history

View file

@ -89,6 +89,37 @@ Recommended interpretation:
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds - `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 - `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) ## 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. 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.

View file

@ -80,6 +80,10 @@ src/
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec_catalog.c - SSH exec command matching and help metadata ├── exec_catalog.c - SSH exec command matching and help metadata
├── exec.c - SSH exec command dispatch ├── 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.c - Local wrapper around the SSH exec interface
├── tntctl_text.c - tntctl local help and diagnostics ├── tntctl_text.c - tntctl local help and diagnostics
├── chat_room.c - Chat room state, message ring, and update sequence ├── 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 ├── message_log_tool.h - Offline log check/recover interface
├── command_catalog.h - COMMAND-mode command metadata interface ├── command_catalog.h - COMMAND-mode command metadata interface
├── exec_catalog.h - SSH exec 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 ├── cli_text.h - Server CLI text interface
├── tntctl_text.h - tntctl text interface ├── tntctl_text.h - tntctl text interface
├── history_view.h - Scroll-state helpers ├── history_view.h - Scroll-state helpers

143
docs/MODULE_PROTOCOL.md Normal file
View file

@ -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.

View file

@ -78,9 +78,13 @@ STRUCTURE
src/commands.c COMMAND-mode command dispatch src/commands.c COMMAND-mode command dispatch
src/exec_catalog.c SSH exec command matching, usage, argument shape src/exec_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch 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.c persistence, search
src/message_log.c messages.log v1 parsing and formatting src/message_log.c messages.log v1 parsing and formatting
src/message_log_tool.c offline messages.log check/recover CLI 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/history_view.c message viewport / scroll state
src/help_text.c full-screen key reference text src/help_text.c full-screen key reference text
src/manual.c concise manual panel rendering src/manual.c concise manual panel rendering

View file

@ -80,6 +80,26 @@ Goal: keep the interface efficient for terminal users without sacrificing simpli
- ✅ improve discoverability of NORMAL and COMMAND mode actions - ✅ improve discoverability of NORMAL and COMMAND mode actions
- ✅ make status lines and help output concise enough for small terminals - ✅ 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 ## Stage 5: Operations and Security
Goal: make public deployment manageable. Goal: make public deployment manageable.

35
include/input_buffer.h Normal file
View file

@ -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 */

15
include/json_text.h Normal file
View file

@ -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 */

24
include/module_protocol.h Normal file
View file

@ -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 */

27
include/module_runtime.h Normal file
View file

@ -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 */

View file

@ -5,7 +5,9 @@
#include "exec_catalog.h" #include "exec_catalog.h"
#include "i18n.h" #include "i18n.h"
#include "input.h" #include "input.h"
#include "json_text.h"
#include "message.h" #include "message.h"
#include "module_runtime.h"
#include "ratelimit.h" #include "ratelimit.h"
#include "utf8.h" #include "utf8.h"
#include <ctype.h> #include <ctype.h>
@ -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, static void resolve_exec_username(const client_t *client, char *buffer,
size_t buf_size) { size_t buf_size) {
if (!buffer || buf_size == 0) { if (!buffer || buf_size == 0) {
@ -188,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) {
if (i > 0) { if (i > 0) {
buffer_append_bytes(output, output_size, &pos, ",", 1); 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); buffer_append_bytes(output, output_size, &pos, "]\n", 2);
} else { } else {
@ -467,6 +427,7 @@ static int exec_command_post(client_t *client, const char *args) {
room_broadcast(g_room, &msg); room_broadcast(g_room, &msg);
notify_mentions(msg.content, client); notify_mentions(msg.content, client);
tnt_module_runtime_publish_message_created(&msg);
if (client_send(client, "posted\n", 7) != 0) { if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;

View file

@ -7,7 +7,9 @@
#include "exec.h" #include "exec.h"
#include "history_view.h" #include "history_view.h"
#include "i18n.h" #include "i18n.h"
#include "input_buffer.h"
#include "message.h" #include "message.h"
#include "module_runtime.h"
#include "ratelimit.h" #include "ratelimit.h"
#include "system_message.h" #include "system_message.h"
#include "tui.h" #include "tui.h"
@ -196,24 +198,6 @@ static int read_channel_exact(client_t *client, char *buf, size_t len,
return (int)got; 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) { static int normal_visible_message_count(const client_t *client) {
if (!client || !client->mute_joins) { if (!client || !client->mute_joins) {
return room_get_message_count(g_room); 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 * spaces so a multi-line paste stays a
* single message instead of N sends. */ * single message instead of N sends. */
bool overflow = false; bool overflow = false;
bool invalid_utf8 = false;
tnt_input_utf8_state_t paste_utf8 = {0};
while (1) { while (1) {
char b; char b;
int k = ssh_channel_read_timeout( 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 * but keep printable bytes that
* followed it. */ * followed it. */
for (int i = 0; i < t; i++) { for (int i = 0; i < t; i++) {
if (!append_paste_byte( int status =
input, tnt_input_append_stream_byte(
(unsigned char)tail[i])) { input, MAX_MESSAGE_LEN,
&paste_utf8,
(unsigned char)tail[i],
true);
if (status &
TNT_INPUT_APPEND_OVERFLOW) {
overflow = true; overflow = true;
} }
if (status &
TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
} }
continue; continue;
} }
if (!append_paste_byte(input, int status = tnt_input_append_stream_byte(
(unsigned char)b)) { input, MAX_MESSAGE_LEN, &paste_utf8,
(unsigned char)b, true);
if (status & TNT_INPUT_APPEND_OVERFLOW) {
overflow = true; 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); tui_render_input(client, input);
if (overflow) { if (overflow || invalid_utf8) {
client_send(client, "\a", 1); 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); room_broadcast(g_room, &msg);
notify_mentions(msg.content, client); 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'; input[0] = '\0';
} }
tui_render_screen(client); tui_render_screen(client);
@ -1011,10 +1020,9 @@ main_loop:
if (client->mode == MODE_INSERT && !client->show_help && if (client->mode == MODE_INSERT && !client->show_help &&
client->command_output[0] == '\0') { client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */ if (b >= 32 && b < 127) { /* ASCII printable */
int len = strlen(input); int status = tnt_input_append_ascii(input,
if (len < MAX_MESSAGE_LEN - 1) { MAX_MESSAGE_LEN, b);
input[len] = b; if (status == TNT_INPUT_APPEND_OK) {
input[len + 1] = '\0';
tui_render_input(client, input); tui_render_input(client, input);
} else { } else {
client_send(client, "\a", 1); client_send(client, "\a", 1);
@ -1038,10 +1046,9 @@ main_loop:
/* Invalid UTF-8 sequence */ /* Invalid UTF-8 sequence */
continue; continue;
} }
int len = strlen(input); int status = tnt_input_append_utf8_sequence(
if (len + char_len <= MAX_MESSAGE_LEN - 1) { input, MAX_MESSAGE_LEN, buf, char_len);
memcpy(input + len, buf, char_len); if (status == TNT_INPUT_APPEND_OK) {
input[len + char_len] = '\0';
tui_render_input(client, input); tui_render_input(client, input);
} else { } else {
client_send(client, "\a", 1); client_send(client, "\a", 1);
@ -1050,10 +1057,10 @@ main_loop:
} else if (client->mode == MODE_COMMAND && !client->show_help && } else if (client->mode == MODE_COMMAND && !client->show_help &&
client->command_output[0] == '\0') { client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */ if (b >= 32 && b < 127) { /* ASCII printable */
size_t len = strlen(client->command_input); int status = tnt_input_append_ascii(
if (len < sizeof(client->command_input) - 1) { client->command_input, sizeof(client->command_input),
client->command_input[len] = b; b);
client->command_input[len + 1] = '\0'; if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client); tui_render_command_input(client);
} else { } else {
client_send(client, "\a", 1); client_send(client, "\a", 1);
@ -1068,10 +1075,10 @@ main_loop:
if (read_bytes != char_len - 1) continue; if (read_bytes != char_len - 1) continue;
} }
if (!utf8_is_valid_sequence(buf, char_len)) continue; if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input); int status = tnt_input_append_utf8_sequence(
if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { client->command_input, sizeof(client->command_input),
memcpy(client->command_input + len, buf, char_len); buf, char_len);
client->command_input[len + char_len] = '\0'; if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client); tui_render_command_input(client);
} else { } else {
client_send(client, "\a", 1); client_send(client, "\a", 1);

147
src/input_buffer.c Normal file
View file

@ -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;
}

343
src/json_text.c Normal file
View file

@ -0,0 +1,343 @@
#include "json_text.h"
#include <ctype.h>
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;
}
}

View file

@ -5,6 +5,7 @@
#include "i18n.h" #include "i18n.h"
#include "message.h" #include "message.h"
#include "message_log_tool.h" #include "message_log_tool.h"
#include "module_runtime.h"
#include "ssh_server.h" #include "ssh_server.h"
#include <signal.h> #include <signal.h>
#include <unistd.h> #include <unistd.h>
@ -238,17 +239,23 @@ int main(int argc, char **argv) {
} }
message_init(); message_init();
if (tnt_module_runtime_init() < 0) {
fprintf(stderr, "Failed to initialize module runtime\n");
return TNT_EXIT_ERROR;
}
/* Create chat room */ /* Create chat room */
g_room = room_create(); g_room = room_create();
if (!g_room) { if (!g_room) {
fprintf(stderr, "Failed to create chat room\n"); fprintf(stderr, "Failed to create chat room\n");
tnt_module_runtime_shutdown();
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
/* Initialize server */ /* Initialize server */
if (ssh_server_init(port) < 0) { if (ssh_server_init(port) < 0) {
fprintf(stderr, "Failed to initialize server\n"); fprintf(stderr, "Failed to initialize server\n");
tnt_module_runtime_shutdown();
room_destroy(g_room); room_destroy(g_room);
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
@ -256,6 +263,7 @@ int main(int argc, char **argv) {
/* Start server (blocking) */ /* Start server (blocking) */
int ret = ssh_server_start(0); int ret = ssh_server_start(0);
tnt_module_runtime_shutdown();
room_destroy(g_room); room_destroy(g_room);
return ret; return ret;
} }

112
src/module_protocol.c Normal file
View file

@ -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;
}

537
src/module_runtime.c Normal file
View file

@ -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 <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <unistd.h>
#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);
}

130
tests/test_module_runtime.sh Executable file
View file

@ -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 ]

View file

@ -11,6 +11,10 @@ endif
# Source files # Source files
UTF8_SRC = ../../src/utf8.c 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_SRC = ../../src/message.c
MESSAGE_LOG_SRC = ../../src/message_log.c MESSAGE_LOG_SRC = ../../src/message_log.c
COMMON_SRC = ../../src/common.c COMMON_SRC = ../../src/common.c
@ -28,7 +32,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.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 .PHONY: all clean run
@ -37,6 +41,18 @@ all: $(TESTS)
test_utf8: test_utf8.c $(UTF8_SRC) test_utf8: test_utf8.c $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(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) test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
@ -80,6 +96,18 @@ run: all
@echo "=== Running UTF-8 Tests ===" @echo "=== Running UTF-8 Tests ==="
./test_utf8 ./test_utf8
@echo "" @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 ===" @echo "=== Running Message Tests ==="
./test_message ./test_message
@echo "" @echo ""

View file

@ -0,0 +1,130 @@
#include "../../include/input_buffer.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}

View file

@ -0,0 +1,95 @@
#include "../../include/json_text.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}

View file

@ -0,0 +1,120 @@
#include "../../include/module_protocol.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}

View file

@ -0,0 +1,155 @@
#include "../../include/module_runtime.h"
#include "../../include/chat_room.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#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;
}