diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 994b387..bf91768 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Version description: Run `tnt --version`, or provide the commit hash. - placeholder: "tnt 1.0.1" + placeholder: "tnt 1.1.0" validations: required: true - type: dropdown diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..876a861 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1,93 @@ +PROJECT METADATA +S: Maintained +F: .github/** +F: .gitignore +F: LICENSE +F: MAINTAINERS +F: demos/** +F: include/.gitkeep +F: scripts/check_maintainers.sh +F: scripts/get_maintainer.sh +F: src/.gitkeep + +TNT CORE +S: Maintained +F: Makefile +F: install.sh +F: tnt.service +F: include/*.h +F: src/*.c +F: include/common.h +F: include/config_defaults.h +F: src/main.c +F: src/common.c +F: src/config_defaults.c + +SSH SERVER AND EXEC INTERFACE +S: Maintained +F: include/bootstrap.h +F: include/ssh_server.h +F: include/exec*.h +F: include/tntctl_text.h +F: src/bootstrap.c +F: src/ssh_server.c +F: src/exec*.c +F: src/tntctl*.c +F: tests/test_exec_mode.sh +F: tests/test_tntctl_cli.sh + +INTERACTIVE TUI +S: Maintained +F: include/input*.h +F: include/tui*.h +F: include/history_view.h +F: src/input*.c +F: src/tui*.c +F: src/history_view.c +F: tests/test_interactive_input.sh +F: tests/test_user_lifecycle.sh + +MESSAGE STORAGE +S: Maintained +F: include/message*.h +F: src/message*.c +F: scripts/logrotate.sh +F: tests/test_message_log_tool.sh +F: tests/test_logrotate.sh +F: docs/MESSAGE_LOG.md + +MODULE CORE INTERFACE +S: Maintained +F: include/module_*.h +F: src/module_*.c +F: scripts/module_check.sh +F: scripts/install_wizard.sh +F: tests/test_module_*.sh +F: tests/test_install_wizard.sh +F: docs/MODULE_PROTOCOL.md +F: docs/INSTALL_LIFECYCLE.md + +PACKAGING AND RELEASE +S: Maintained +F: packaging/** +F: scripts/healthcheck.sh +F: scripts/package_*.sh +F: scripts/check_release_ref.sh +F: scripts/release_check.sh +F: scripts/setup_cron.sh +F: tests/test_release_artifact_gate.sh +F: tests/test_source_archive.sh +F: docs/DEPLOYMENT.md +F: docs/CICD.md + +TESTING +S: Maintained +F: tests/** + +DOCUMENTATION +S: Maintained +F: README.md +F: SECURITY.md +F: docs/** +F: tnt.1 +F: tntctl.1 diff --git a/Makefile b/Makefile index 148eef6..608d2c6 100644 --- a/Makefile +++ b/Makefile @@ -133,6 +133,10 @@ script-test: all @echo "Running script tests..." @cd tests && ./test_cli_options.sh @cd tests && ./test_docs_help_surface.sh + @cd tests && ./test_get_maintainer.sh + @cd tests && ./test_check_maintainers.sh + @cd tests && ./test_module_check.sh + @cd tests && ./test_install_wizard.sh @cd tests && ./test_logrotate.sh @cd tests && ./test_message_log_tool.sh @cd tests && ./test_source_archive.sh diff --git a/README.md b/README.md index 1a140f8..4309e95 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,20 @@ A minimalist terminal chat server with Vim-style interface over SSH. ### Installation -**One-liner:** +**Pinned release installer:** ```sh -curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/v1.1.0/install.sh | VERSION=v1.1.0 sh ``` The installer verifies downloaded release binaries against `checksums.txt` before installing them. Older releases may provide only `tnt`; newer releases also install `tntctl`. +For convenience during testing, the moving `main` installer is also available: + +```sh +curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +``` + **From source:** ```sh git clone https://github.com/m1ngsama/TNT.git @@ -36,6 +42,18 @@ sudo make install **Binary releases:** https://github.com/m1ngsama/TNT/releases +**Interactive setup guide:** + +After installing the binary, operators can generate a reviewable environment +file and choose a module profile with the terminal setup wizard: + +```sh +scripts/install_wizard.sh --output tnt.env +``` + +The wizard does not restart services or install modules. Review the generated +file, then install it manually as your systemd environment file. + ### Running ```sh @@ -422,6 +440,33 @@ replace placeholder checksums, and run: SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check ``` +## Module Development + +TNT modules are external processes that speak the `tnt.module.v1` JSON Lines +protocol over stdin/stdout. TNT core stays minimal, Unix-like, text-first, and +reliable; modules are the optional layer for richer modern-terminal interaction, +visual presentation, workflow automation, and future community features. + +The core server owns the protocol contract and the runtime supervisor; +community module examples and module packages live in the public companion +repository: + +https://github.com/m1ngsama/tnt-modules + +Before enabling a third-party module, check its manifest and handshake: + +```sh +scripts/module_check.sh /path/to/module +``` + +Enable reviewed modules explicitly: + +```sh +TNT_MODULE_PATHS=/opt/tnt-modules/echo-module:/opt/tnt-modules/other-module tnt +``` + +Unset `TNT_MODULE_PATHS` and restart TNT to return to the plain core server. + ## Files ``` @@ -432,11 +477,11 @@ tnt.service - systemd service unit ``` The persisted chat-history format is documented in -[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. +[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). The core module protocol is +documented 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) @@ -457,6 +502,7 @@ Delete `motd.txt` to disable the MOTD. - [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual - [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide - [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages +- [Installation Lifecycle](docs/INSTALL_LIFECYCLE.md) - Setup wizard, module selection, and rollback model - [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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 396c21f..9407510 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,20 @@ ## Unreleased +## 1.1.0 - 2026-06-16 + ### Added +- Added `scripts/module_check.sh` for validating third-party module manifests, + entrypoints, protocol handshakes, permissions, events, and TNT minimum + version compatibility before deployment. +- Added `scripts/install_wizard.sh`, a non-mutating installer guide that writes + reviewable environment files for core-only, all-compatible, selected, and + manually supplied module deployments. +- Added `MAINTAINERS` plus maintainer lookup and coverage checks, giving the + core runtime, module interface, packaging, tests, and documentation explicit + ownership patterns. +- Added regression tests for module manifest compatibility, install wizard + selection behavior, maintainer lookup, and maintainer coverage. - Added a release tag/version guard used by the GitHub release workflow, so a `vX.Y.Z` tag must match `TNT_VERSION` before release assets are built. - Added `make package-publish-check` for verifying Arch/Homebrew source @@ -51,6 +64,15 @@ and server survival stay responsive. ### Changed +- Module names are now restricted to lowercase ASCII letters, digits, and + hyphens, with a length cap that keeps generated `module:` senders + within the public message username limit. +- The module runtime now disables modules that flood a single event with too + many responses or repeatedly emit invalid response records, keeping bad + downstream modules isolated from the core server. +- Module documentation now treats the public companion module repository as + downstream of the TNT core protocol and keeps TNT's core Unix surface + minimal, text-first, and robust. - INSERT-mode chrome now only advertises message sending and `Esc` to NORMAL; `? keys` appears only in NORMAL mode, matching where help keys work. - Dismissing MOTD now returns first-time users to INSERT mode, and `Ctrl+C` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0024080..d929e4a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -97,6 +97,20 @@ utf8.c → UTF-8 string handling 4. Put shared localized strings in `src/i18n_text.c`. 5. Add or update the narrowest unit/integration test for the behavior. +## Adding Modules + +TNT core owns the module protocol, runtime supervisor, and compatibility tests. +Community modules and module examples live in the companion repository: + +```text +https://github.com/m1ngsama/tnt-modules +``` + +For core protocol or runtime changes, update `docs/MODULE_PROTOCOL.md`, add or +update tests in this repository, and keep `scripts/module_check.sh` aligned with +the manifest and handshake rules. For new module implementations, contribute to +the companion module repository instead of adding them to TNT core. + ## Debugging Tips ```sh diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 1c5bdc7..53d272e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,14 +2,14 @@ ## Quick Install -One-line install (latest release): +Pinned release install: ```bash -curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/v1.1.0/install.sh | VERSION=v1.1.0 sh ``` -Specific version: +Moving latest-release installer, convenient for test deployments: ```bash -VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh ``` ## Manual Install @@ -82,6 +82,20 @@ sudo systemctl restart tnt The service uses `StateDirectory=tnt`, so systemd creates `/var/lib/tnt` automatically. Use `TNT_STATE_DIR` or `tnt -d DIR` when running outside systemd to avoid depending on the current working directory. +To generate a reviewable environment file with an interactive terminal setup +wizard: + +```bash +scripts/install_wizard.sh --output tnt.env +sudo install -m 600 tnt.env /etc/default/tnt +sudo systemctl restart tnt +``` + +The wizard can choose a core-only deployment, scan a module root, select +individual modules, or validate a manually entered module path list. It never +downloads modules, edits systemd units, or restarts TNT by itself. See +`docs/INSTALL_LIFECYCLE.md` for the full operator lifecycle. + Recommended interpretation: - `TNT_MAX_CONNECTIONS`: global connection ceiling @@ -109,7 +123,9 @@ For that profile: - 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`. + protocol in `docs/MODULE_PROTOCOL.md` with `scripts/module_check.sh`. +- Develop and publish community modules in the public companion repository: + `https://github.com/m1ngsama/tnt-modules`. Enable modules explicitly with `TNT_MODULE_PATHS`, using a colon-separated list of module directories: @@ -183,8 +199,8 @@ sudo firewall-cmd --reload # Stop service sudo systemctl stop tnt -# Re-run install script -curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +# Re-run the pinned installer for the version you want +curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/v1.1.0/install.sh | VERSION=v1.1.0 sh # Start service sudo systemctl start tnt diff --git a/docs/INSTALL_LIFECYCLE.md b/docs/INSTALL_LIFECYCLE.md new file mode 100644 index 0000000..3ee153a --- /dev/null +++ b/docs/INSTALL_LIFECYCLE.md @@ -0,0 +1,47 @@ +# Installation Lifecycle + +TNT core is the small Unix tool: text-first, compatible, and reliable. Modules +are downstream enhancements for modern terminal UX, visuals, automation, and +community features. Module failure must not take TNT down. + +## Flow + +1. Install `tnt` and `tntctl`. +2. Choose a profile: `core`, `select`, `all`, or `manual`. +3. Validate modules with `scripts/module_check.sh`. +4. Generate an env file with `scripts/install_wizard.sh`. +5. Review, install, restart, smoke-test. +6. Roll back by removing `TNT_MODULE_PATHS` and restarting. + +## Commands + +Generate a reviewable env file: + +```sh +scripts/install_wizard.sh --output tnt.env +``` + +Preview all modules under a root: + +```sh +scripts/install_wizard.sh --print-modules --module-root /opt/tnt-modules +``` + +Generate an all-valid-modules env file: + +```sh +TNT_SETUP_PROFILE=all \ +TNT_SETUP_MODULE_ROOT=/opt/tnt-modules \ +scripts/install_wizard.sh --non-interactive --output tnt.env +``` + +Activate manually: + +```sh +sudo install -m 600 tnt.env /etc/default/tnt +sudo systemctl restart tnt +ssh -p 2222 localhost health +``` + +The wizard never installs binaries, downloads modules, edits systemd units, or +restarts services. It only makes choices visible and emits config. diff --git a/docs/MODULE_PROTOCOL.md b/docs/MODULE_PROTOCOL.md index f94baa5..a98e85c 100644 --- a/docs/MODULE_PROTOCOL.md +++ b/docs/MODULE_PROTOCOL.md @@ -11,11 +11,17 @@ 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. +TNT core should stay conservative: text-first, terminal-compatible, Unix-like, +and easy to deploy over plain SSH. The core server is the compatibility and +reliability base layer; its basic chat path must continue working even when all +modules are disabled or broken. + +Modules are the extension surface for modern terminal experiences: richer +interaction, stronger visual presentation, workflow automation, terminal-native +media, and future community/IM features. A powerful module stack may provide a +much richer product experience than the core server alone, but that power must +remain supervised, optional, and rollback-friendly. Integrating a module with +TNT must not make plain terminal users lose the basic chat path. ## Compatibility @@ -32,6 +38,15 @@ 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. +Community module examples and module packages live in the companion repository: +https://github.com/m1ngsama/tnt-modules + +Before enabling a module, operators and module authors should run: + +```sh +scripts/module_check.sh /path/to/module +``` + 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. @@ -45,6 +60,7 @@ Each module directory should include `tnt-module.json`: "protocol": "tnt.module.v1", "name": "echo", "version": "0.1.0", + "tnt_min_version": "1.1.0", "description": "Echoes public messages for testing", "entrypoint": "./echo-module.sh", "permissions": ["message:read", "message:create"], @@ -55,18 +71,25 @@ Each module directory should include `tnt-module.json`: Required fields: - `protocol`: protocol compatibility string -- `name`: stable module id, lowercase ASCII, `a-z`, `0-9`, and `-` +- `name`: stable module id, max 56 characters, lowercase ASCII, `a-z`, `0-9`, + and `-`; it must start and end with a letter or digit - `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 +Optional compatibility fields: + +- `tnt_min_version`: minimum TNT core version the module expects. TNT runtime + treats unknown manifest fields as advisory, but deployment tools should reject + modules whose minimum version is newer than the target TNT core. + ## 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"}} +{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.1.0"}} ``` The module should answer: @@ -132,6 +155,8 @@ Module error: total queued output. - TNT should disable a module after repeated invalid JSON, protocol errors, or timeout failures. +- TNT caps the number of module responses accepted for one event. Modules + should emit only the messages needed for that event, or `event.ok`. - Modules must never receive private messages unless they request and are granted an explicit private-message permission. diff --git a/include/common.h b/include/common.h index 962ec2a..8cc6363 100644 --- a/include/common.h +++ b/include/common.h @@ -14,7 +14,7 @@ #include "config_defaults.h" /* Project Metadata */ -#define TNT_VERSION "1.0.1" +#define TNT_VERSION "1.1.0" /* Public process/exec exit statuses. TNT follows the common sysexits(3) * convention for usage errors while keeping runtime failures portable. */ diff --git a/include/module_runtime.h b/include/module_runtime.h index 401e4e2..232918a 100644 --- a/include/module_runtime.h +++ b/include/module_runtime.h @@ -5,6 +5,9 @@ #define TNT_MAX_MODULES 8 #define TNT_MODULE_QUEUE_LIMIT 128 +/* Module-created messages use "module:" as the public sender. Keep the + * module id short enough to fit message_t.username including the NUL byte. */ +#define TNT_MODULE_NAME_MAX (MAX_USERNAME_LEN - 8) typedef struct { char name[64]; diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 22c0c06..65fecaf 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tnt-chat pkgdesc = SSH-native terminal chat server with a Vim-style interface - pkgver = 1.0.1 + pkgver = 1.1.0 pkgrel = 1 url = https://github.com/m1ngsama/TNT arch = x86_64 @@ -9,7 +9,7 @@ pkgbase = tnt-chat makedepends = gcc makedepends = make depends = libssh - source = tnt-chat-v1.0.1-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz + source = tnt-chat-v1.1.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.1.0/tnt-chat-v1.1.0-source.tar.gz source = tnt-chat.sysusers sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index dae9f15..3e0d8f3 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: M1ng pkgname=tnt-chat -pkgver=1.0.1 +pkgver=1.1.0 pkgrel=1 pkgdesc='SSH-native terminal chat server with a Vim-style interface' arch=('x86_64' 'aarch64') diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index b3b8299..6e46ca0 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -1,4 +1,4 @@ -tnt-chat (1.0.1-1) unstable; urgency=medium +tnt-chat (1.1.0-1) unstable; urgency=medium * Initial package draft. diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 0b06bd8..7890b1b 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,7 +1,7 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz" + url "https://github.com/m1ngsama/TNT/releases/download/v1.1.0/tnt-chat-v1.1.0-source.tar.gz" sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" diff --git a/scripts/check_maintainers.sh b/scripts/check_maintainers.sh new file mode 100755 index 0000000..417f50c --- /dev/null +++ b/scripts/check_maintainers.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# Verify that repository paths are covered by MAINTAINERS. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +GET_MAINTAINER="$ROOT/scripts/get_maintainer.sh" + +usage() { + cat <<'USAGE' +Usage: scripts/check_maintainers.sh [PATH ...] + +With no paths, checks git-tracked files plus untracked, non-ignored files. +Fails if any path maps only to UNKNOWN. +USAGE +} + +fail() { + echo "check-maintainers: $*" >&2 + exit 1 +} + +is_generated_path() { + path=$1 + + case "$path" in + obj/*|dist/*|tnt|tntctl|tests/*.log|tests/host_key*|tests/messages.log|tests/unit/*.o|tests/unit/test_messages.log) + return 0 + ;; + tests/unit/test_*) + case "$path" in + *.c) + return 1 + ;; + esac + [ -x "$ROOT/$path" ] && return 0 + ;; + esac + + return 1 +} + +list_repo_files() { + if [ "${TNT_CHECK_MAINTAINERS_NO_GIT:-0}" != "1" ] && + command -v git >/dev/null 2>&1 && + git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT" ls-files --cached --others --exclude-standard + else + find "$ROOT" -type f ! -path "$ROOT/.git/*" | + sed "s#^$ROOT/##" | + while IFS= read -r path; do + is_generated_path "$path" && continue + printf '%s\n' "$path" + done + fi +} + +check_path() { + path=$1 + output=$("$GET_MAINTAINER" "$path") + if printf '%s\n' "$output" | + awk -F'\t' '$2 != "UNKNOWN" { found = 1 } END { exit found ? 0 : 1 }'; then + return 0 + fi + printf '%s\n' "$path" + return 1 +} + +[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || { + usage + exit 0 +} + +[ -x "$GET_MAINTAINER" ] || fail "missing executable: $GET_MAINTAINER" + +missing=0 +if [ "$#" -gt 0 ]; then + for path in "$@"; do + check_path "$path" || missing=1 + done +else + while IFS= read -r path; do + [ -n "$path" ] || continue + check_path "$path" >/dev/null || { + printf '%s\n' "$path" + missing=1 + } + done <sectionstatusmatched-pattern +USAGE +} + +fail() { + echo "get-maintainer: $*" >&2 + exit 1 +} + +normalize_path() { + path=$1 + case "$path" in + "$ROOT"/*) + path=${path#"$ROOT"/} + ;; + ./*) + path=${path#./} + ;; + esac + printf '%s\n' "$path" +} + +emit_matches() { + target=$1 + section= + status= + matched=0 + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*) + continue + ;; + 'S: '*) + status=${line#S: } + ;; + 'F: '*) + pattern=${line#F: } + case "$target" in + $pattern) + printf '%s\t%s\t%s\t%s\n' "$target" "$section" "$status" "$pattern" + matched=1 + ;; + esac + ;; + [A-Z]*) + section=$line + status= + ;; + esac + done < "$MAINTAINERS_FILE" + + [ "$matched" -eq 1 ] || printf '%s\t%s\t%s\t%s\n' "$target" UNKNOWN unknown - +} + +[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || { + usage + exit 0 +} + +[ "$#" -gt 0 ] || { + usage >&2 + exit 64 +} + +[ -f "$MAINTAINERS_FILE" ] || fail "missing MAINTAINERS file: $MAINTAINERS_FILE" + +for path in "$@"; do + emit_matches "$(normalize_path "$path")" +done diff --git a/scripts/install_wizard.sh b/scripts/install_wizard.sh new file mode 100755 index 0000000..a9ff170 --- /dev/null +++ b/scripts/install_wizard.sh @@ -0,0 +1,389 @@ +#!/bin/sh +# Interactive TNT setup wizard. Generates an environment file; does not install, +# restart, or enable services by itself. + +set -eu + +PROFILE=${TNT_SETUP_PROFILE:-} +PORT_VALUE=${TNT_SETUP_PORT:-2222} +BIND_ADDR=${TNT_SETUP_BIND_ADDR:-0.0.0.0} +STATE_DIR=${TNT_SETUP_STATE_DIR:-/var/lib/tnt} +PUBLIC_HOST=${TNT_SETUP_PUBLIC_HOST:-} +MAX_CONNECTIONS=${TNT_SETUP_MAX_CONNECTIONS:-64} +MODULE_ROOT=${TNT_SETUP_MODULE_ROOT:-/opt/tnt-modules} +MODULE_PATHS=${TNT_SETUP_MODULE_PATHS:-} +MODULE_SELECTION=${TNT_SETUP_MODULE_SELECTION:-} +OUTPUT=${TNT_SETUP_OUTPUT:-} +NON_INTERACTIVE=0 +PRINT_MODULES=0 + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +CHECKER="$SCRIPT_DIR/module_check.sh" +TMPDIR_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/tnt-install-wizard.XXXXXX") +MODULE_INDEX="$TMPDIR_ROOT/modules.index" + +cleanup() { + rm -rf "$TMPDIR_ROOT" +} +trap cleanup EXIT INT TERM + +usage() { + cat <<'USAGE' +Usage: scripts/install_wizard.sh [--non-interactive] [--output FILE] [--module-root DIR] + scripts/install_wizard.sh --print-modules [--module-root DIR] + +Generates a TNT environment file for review. It never installs binaries, +rewrites systemd units, restarts TNT, or downloads modules. + +Profiles: + core no modules + all enable every valid module under --module-root + select choose valid modules from --module-root + manual use TNT_SETUP_MODULE_PATHS as a colon-separated module path list + +Non-interactive environment: + TNT_SETUP_PROFILE=core|all|select|manual + TNT_SETUP_PORT=2222 + TNT_SETUP_BIND_ADDR=0.0.0.0 + TNT_SETUP_STATE_DIR=/var/lib/tnt + TNT_SETUP_PUBLIC_HOST=chat.example.com + TNT_SETUP_MAX_CONNECTIONS=64 + TNT_SETUP_MODULE_ROOT=/opt/tnt-modules + TNT_SETUP_MODULE_SELECTION=1,3 + TNT_SETUP_MODULE_PATHS=/opt/tnt-modules/a:/opt/tnt-modules/b +USAGE +} + +fail() { + echo "install-wizard: $*" >&2 + exit 1 +} + +is_uint() { + case "${1:-}" in + ''|*[!0-9]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +is_port() { + is_uint "$1" && [ "$1" -ge 1 ] && [ "$1" -le 65535 ] +} + +safe_scalar() { + case "${1:-}" in + *[!A-Za-z0-9._:/@-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +safe_optional_scalar() { + [ -z "${1:-}" ] || safe_scalar "$1" +} + +safe_module_path() { + case "${1:-}" in + ''|*:*) + return 1 + ;; + *[!A-Za-z0-9._/@+-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +json_string_field() { + key=$1 + file=$2 + sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$file" | + head -n 1 +} + +prompt_value() { + label=$1 + default=$2 + printf '%s [%s]: ' "$label" "$default" >&2 + IFS= read -r answer || answer= + [ -n "$answer" ] || answer=$default + printf '%s\n' "$answer" +} + +prompt_optional() { + label=$1 + default=$2 + if [ -n "$default" ]; then + printf '%s [%s]: ' "$label" "$default" >&2 + else + printf '%s: ' "$label" >&2 + fi + IFS= read -r answer || answer= + [ -n "$answer" ] || answer=$default + printf '%s\n' "$answer" +} + +print_profiles() { + cat >&2 <<'PROFILES' + +Deployment profile: + 1) core - run TNT without modules + 2) all - enable every valid module under the module root + 3) select - choose valid modules from the module root + 4) manual - enter a colon-separated module path list +PROFILES +} + +profile_from_choice() { + case "$1" in + 1|core) printf 'core\n' ;; + 2|all) printf 'all\n' ;; + 3|select) printf 'select\n' ;; + 4|manual) printf 'manual\n' ;; + *) fail "unknown profile: $1" ;; + esac +} + +scan_modules() { + : > "$MODULE_INDEX" + [ -d "$MODULE_ROOT" ] || return 0 + + find "$MODULE_ROOT" -mindepth 1 -maxdepth 1 -type d | sort | + while IFS= read -r module_dir; do + [ -f "$module_dir/tnt-module.json" ] || continue + safe_module_path "$module_dir" || continue + name=$(json_string_field name "$module_dir/tnt-module.json") + [ -n "$name" ] || name=$(basename "$module_dir") + index=$(($(wc -l < "$MODULE_INDEX" | tr -d ' ') + 1)) + if [ -x "$CHECKER" ] && "$CHECKER" "$module_dir" >/dev/null 2>&1; then + status=ok + else + status=invalid + fi + printf '%s|%s|%s|%s\n' "$index" "$name" "$module_dir" "$status" >> "$MODULE_INDEX" + done +} + +print_modules() { + if [ ! -s "$MODULE_INDEX" ]; then + echo "No modules found under $MODULE_ROOT" >&2 + return + fi + + echo "" >&2 + echo "Modules under $MODULE_ROOT:" >&2 + while IFS='|' read -r index name path status; do + printf ' %s) [%s] %s %s\n' "$index" "$status" "$name" "$path" >&2 + done < "$MODULE_INDEX" +} + +print_modules_stdout() { + if [ ! -s "$MODULE_INDEX" ]; then + return 0 + fi + + while IFS='|' read -r index name path status; do + printf '%s\t%s\t%s\t%s\n' "$index" "$status" "$name" "$path" + done < "$MODULE_INDEX" +} + +join_path_file() { + result= + while IFS= read -r path; do + [ -n "$path" ] || continue + if [ -z "$result" ]; then + result=$path + else + result="$result:$path" + fi + done < "$1" + printf '%s\n' "$result" +} + +valid_module_paths_from_scan() { + paths="$TMPDIR_ROOT/valid.paths" + awk -F'|' '$4 == "ok" { print $3 }' "$MODULE_INDEX" > "$paths" + join_path_file "$paths" +} + +selected_module_paths_from_scan() { + selection=$1 + selected="$TMPDIR_ROOT/selected.paths" + : > "$selected" + + [ -n "$selection" ] || fail "no modules selected" + for item in $(printf '%s\n' "$selection" | tr ',' ' '); do + is_uint "$item" || fail "invalid module selection: $item" + line=$(awk -F'|' -v n="$item" '$1 == n { print; exit }' "$MODULE_INDEX") + [ -n "$line" ] || fail "module selection not found: $item" + status=$(printf '%s\n' "$line" | awk -F'|' '{ print $4 }') + [ "$status" = "ok" ] || fail "selected module is invalid: $item" + printf '%s\n' "$line" | awk -F'|' '{ print $3 }' >> "$selected" + done + + join_path_file "$selected" +} + +validate_module_path_list() { + list=$1 + [ -n "$list" ] || return 0 + + printf '%s\n' "$list" | tr ':' '\n' | + while IFS= read -r module_dir; do + [ -n "$module_dir" ] || continue + safe_module_path "$module_dir" || + fail "unsafe module path: $module_dir" + [ -d "$module_dir" ] || + fail "module directory does not exist: $module_dir" + [ -x "$CHECKER" ] || + fail "module checker is missing or not executable: $CHECKER" + "$CHECKER" "$module_dir" >/dev/null || + fail "module failed validation: $module_dir" + done +} + +write_config() { + destination=$1 + { + echo "# Generated by scripts/install_wizard.sh" + echo "# Review before installing as /etc/default/tnt or a systemd EnvironmentFile." + printf 'PORT=%s\n' "$(shell_quote "$PORT_VALUE")" + printf 'TNT_BIND_ADDR=%s\n' "$(shell_quote "$BIND_ADDR")" + printf 'TNT_STATE_DIR=%s\n' "$(shell_quote "$STATE_DIR")" + printf 'TNT_MAX_CONNECTIONS=%s\n' "$(shell_quote "$MAX_CONNECTIONS")" + if [ -n "$PUBLIC_HOST" ]; then + printf 'TNT_PUBLIC_HOST=%s\n' "$(shell_quote "$PUBLIC_HOST")" + fi + if [ -n "$MODULE_PATHS" ]; then + printf 'TNT_MODULE_PATHS=%s\n' "$(shell_quote "$MODULE_PATHS")" + else + echo "# TNT_MODULE_PATHS intentionally unset: core-only deployment." + fi + } > "$destination" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --non-interactive) + NON_INTERACTIVE=1 + shift + ;; + --output) + [ "$#" -ge 2 ] || fail "missing value for --output" + OUTPUT=$2 + shift 2 + ;; + --module-root) + [ "$#" -ge 2 ] || fail "missing value for --module-root" + MODULE_ROOT=$2 + shift 2 + ;; + --print-modules) + PRINT_MODULES=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +if [ "$PRINT_MODULES" -eq 1 ]; then + safe_scalar "$MODULE_ROOT" || fail "unsafe module root: $MODULE_ROOT" + scan_modules + print_modules_stdout + exit 0 +fi + +if [ "$NON_INTERACTIVE" -eq 0 ]; then + echo "=== TNT Setup Wizard ===" >&2 + print_profiles + choice=$(prompt_value "Choose profile" "${PROFILE:-core}") + PROFILE=$(profile_from_choice "$choice") + PORT_VALUE=$(prompt_value "Port" "$PORT_VALUE") + BIND_ADDR=$(prompt_value "Bind address" "$BIND_ADDR") + STATE_DIR=$(prompt_value "State directory" "$STATE_DIR") + PUBLIC_HOST=$(prompt_optional "Public host" "$PUBLIC_HOST") + MAX_CONNECTIONS=$(prompt_value "Max connections" "$MAX_CONNECTIONS") + case "$PROFILE" in + all|select) + MODULE_ROOT=$(prompt_value "Module root" "$MODULE_ROOT") + ;; + manual) + MODULE_PATHS=$(prompt_optional "Module paths" "$MODULE_PATHS") + ;; + esac +else + [ -n "$PROFILE" ] || PROFILE=core + PROFILE=$(profile_from_choice "$PROFILE") +fi + +is_port "$PORT_VALUE" || fail "invalid port: $PORT_VALUE" +safe_scalar "$BIND_ADDR" || fail "unsafe bind address: $BIND_ADDR" +safe_scalar "$STATE_DIR" || fail "unsafe state directory: $STATE_DIR" +safe_optional_scalar "$PUBLIC_HOST" || fail "unsafe public host: $PUBLIC_HOST" +is_uint "$MAX_CONNECTIONS" && [ "$MAX_CONNECTIONS" -gt 0 ] || + fail "invalid max connections: $MAX_CONNECTIONS" + +case "$PROFILE" in + core) + MODULE_PATHS= + ;; + all) + safe_scalar "$MODULE_ROOT" || fail "unsafe module root: $MODULE_ROOT" + scan_modules + [ "$NON_INTERACTIVE" -eq 1 ] || print_modules + MODULE_PATHS=$(valid_module_paths_from_scan) + ;; + select) + safe_scalar "$MODULE_ROOT" || fail "unsafe module root: $MODULE_ROOT" + scan_modules + print_modules + if [ "$NON_INTERACTIVE" -eq 0 ]; then + MODULE_SELECTION=$(prompt_optional "Select module numbers, comma-separated" "$MODULE_SELECTION") + fi + MODULE_PATHS=$(selected_module_paths_from_scan "$MODULE_SELECTION") + ;; + manual) + validate_module_path_list "$MODULE_PATHS" + ;; +esac + +validate_module_path_list "$MODULE_PATHS" + +if [ -n "$OUTPUT" ]; then + tmp="$OUTPUT.tmp.$$" + write_config "$tmp" + mv "$tmp" "$OUTPUT" + echo "install-wizard: wrote $OUTPUT" >&2 +else + write_config /dev/stdout +fi + +cat >&2 <<'NEXT' + +Next steps: + 1. Review the generated environment file. + 2. Install it manually, for example: sudo install -m 600 FILE /etc/default/tnt + 3. Restart TNT manually when ready: sudo systemctl restart tnt + 4. Roll back modules by removing TNT_MODULE_PATHS and restarting TNT. +NEXT diff --git a/scripts/module_check.sh b/scripts/module_check.sh new file mode 100755 index 0000000..2734542 --- /dev/null +++ b/scripts/module_check.sh @@ -0,0 +1,205 @@ +#!/bin/sh +# Validate a TNT external-process module directory without starting TNT. + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TNT_VERSION_OVERRIDE=${TNT_MODULE_CHECK_TNT_VERSION:-} + +fail() { + echo "module-check: $*" >&2 + exit 1 +} + +usage() { + cat <<'USAGE' +Usage: scripts/module_check.sh [--tnt-version VERSION] MODULE_DIR + +Checks: + - tnt-module.json exists and declares tnt.module.v1 + - optional tnt_min_version is compatible with the target TNT version + - module name follows a-z, 0-9, and '-' rules, max 56 chars + - entrypoint is safe, relative, and executable + - required message read/create permissions and message.created event exist + - entrypoint answers the TNT JSONL handshake with handshake.ok +USAGE +} + +json_string_field() { + key=$1 + file=$2 + sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$file" | + head -n 1 +} + +normalize_version() { + version=${1#v} + case "$version" in + ''|*[!0-9.]*|.*|*.) + return 1 + ;; + esac + printf '%s\n' "$version" +} + +version_ge() { + current=$(normalize_version "$1") || return 1 + required=$(normalize_version "$2") || return 1 + + current_major=$(printf '%s\n' "$current" | awk -F. '{ print $1 + 0 }') + current_minor=$(printf '%s\n' "$current" | awk -F. '{ print $2 + 0 }') + current_patch=$(printf '%s\n' "$current" | awk -F. '{ print $3 + 0 }') + required_major=$(printf '%s\n' "$required" | awk -F. '{ print $1 + 0 }') + required_minor=$(printf '%s\n' "$required" | awk -F. '{ print $2 + 0 }') + required_patch=$(printf '%s\n' "$required" | awk -F. '{ print $3 + 0 }') + + [ "$current_major" -gt "$required_major" ] && return 0 + [ "$current_major" -lt "$required_major" ] && return 1 + [ "$current_minor" -gt "$required_minor" ] && return 0 + [ "$current_minor" -lt "$required_minor" ] && return 1 + [ "$current_patch" -ge "$required_patch" ] +} + +target_tnt_version() { + if [ -n "$TNT_VERSION_OVERRIDE" ]; then + normalize_version "$TNT_VERSION_OVERRIDE" || fail "invalid TNT version: $TNT_VERSION_OVERRIDE" + return + fi + + version_file="$SCRIPT_DIR/../include/common.h" + if [ -f "$version_file" ]; then + version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' "$version_file" | head -n 1) + if [ -n "$version" ]; then + normalize_version "$version" || fail "invalid TNT version: $version" + return + fi + fi + + printf '0.0.0\n' +} + +valid_module_name() { + [ "${#1}" -le 56 ] || return 1 + printf '%s\n' "$1" | + awk '/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/ { ok = 1 } END { exit ok ? 0 : 1 }' +} + +safe_entrypoint() { + printf '%s\n' "$1" | + awk ' + length($0) == 0 { exit 1 } + substr($0, 1, 1) == "/" { exit 1 } + index($0, "..") > 0 { exit 1 } + /[[:space:][:cntrl:]\|;&`$<>\\]/ { exit 1 } + { exit 0 } + ' +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --tnt-version) + [ "$#" -ge 2 ] || fail "missing value for --tnt-version" + TNT_VERSION_OVERRIDE=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + fail "unknown option: $1" + ;; + *) + break + ;; + esac +done + +[ "$#" -le 1 ] || fail "too many arguments" + +module_dir=${1:-} +[ -n "$module_dir" ] || { + usage >&2 + exit 2 +} + +[ -d "$module_dir" ] || fail "module directory does not exist: $module_dir" +manifest="$module_dir/tnt-module.json" +[ -f "$manifest" ] || fail "missing manifest: $manifest" + +protocol=$(json_string_field protocol "$manifest") +name=$(json_string_field name "$manifest") +entrypoint=$(json_string_field entrypoint "$manifest") +tnt_min_version=$(json_string_field tnt_min_version "$manifest") +tnt_version=$(target_tnt_version) + +[ "$protocol" = "tnt.module.v1" ] || + fail "unsupported protocol: ${protocol:-missing}" +[ -z "$tnt_min_version" ] || + version_ge "$tnt_version" "$tnt_min_version" || + fail "module requires TNT >= $tnt_min_version, target is $tnt_version" +valid_module_name "$name" || + fail "invalid module name: ${name:-missing}" +safe_entrypoint "$entrypoint" || + fail "unsafe entrypoint: ${entrypoint:-missing}" + +grep -q '"message:read"' "$manifest" || + fail "missing permission: message:read" +grep -q '"message:create"' "$manifest" || + fail "missing permission: message:create" +grep -q '"message.created"' "$manifest" || + fail "missing event: message.created" + +entry_path="$module_dir/$entrypoint" +[ -f "$entry_path" ] || fail "entrypoint does not exist: $entry_path" +[ -x "$entry_path" ] || fail "entrypoint is not executable: $entry_path" +case "$entrypoint" in + */*) entry_run=$entrypoint ;; + *) entry_run="./$entrypoint" ;; +esac + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-module-check.XXXXXX") +cleanup() { + [ -z "${writer_pid:-}" ] || kill "$writer_pid" 2>/dev/null || true + [ -z "${module_pid:-}" ] || kill "$module_pid" 2>/dev/null || true + rm -rf "$tmpdir" +} +trap cleanup EXIT INT TERM + +in_pipe="$tmpdir/stdin" +out_file="$tmpdir/stdout" +err_file="$tmpdir/stderr" +mkfifo "$in_pipe" + +( + cd "$module_dir" + "$entry_run" <"$in_pipe" >"$out_file" 2>"$err_file" +) & +module_pid=$! + +printf '%s\n' "{\"type\":\"handshake\",\"protocol\":\"tnt.module.v1\",\"server\":{\"name\":\"tnt\",\"version\":\"$tnt_version\"}}" >"$in_pipe" & +writer_pid=$! + +i=0 +while [ "$i" -lt 20 ]; do + if [ -s "$out_file" ]; then + break + fi + if ! kill -0 "$module_pid" 2>/dev/null; then + break + fi + i=$((i + 1)) + sleep 0.1 +done + +line=$(sed -n '1p' "$out_file" 2>/dev/null || true) +printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"handshake.ok"' || + fail "entrypoint did not return handshake.ok" +printf '%s\n' "$line" | grep -q '"protocol"[[:space:]]*:[[:space:]]*"tnt.module.v1"' || + fail "entrypoint handshake used the wrong protocol" + +echo "module-check: ok $name" diff --git a/src/module_runtime.c b/src/module_runtime.c index 445de70..007df88 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -16,6 +16,8 @@ #define TNT_MODULE_LINE_MAX 4096 #define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000 #define TNT_MODULE_RESPONSE_TIMEOUT_MS 100 +#define TNT_MODULE_MAX_RESPONSES_PER_EVENT 8 +#define TNT_MODULE_MAX_INVALID_RESPONSES 3 struct client; void notify_mentions(const char *content, const struct client *sender); @@ -25,6 +27,7 @@ typedef struct module_process { pid_t pid; int stdin_fd; int stdout_fd; + int invalid_responses; bool active; } module_process_t; @@ -33,6 +36,12 @@ typedef struct module_event_node { struct module_event_node *next; } module_event_node_t; +typedef enum module_response_action { + MODULE_RESPONSE_CONTINUE, + MODULE_RESPONSE_DONE, + MODULE_RESPONSE_INVALID +} module_response_action_t; + static module_process_t g_modules[TNT_MAX_MODULES]; static int g_module_count = 0; static pthread_t g_module_thread; @@ -61,6 +70,31 @@ static bool is_safe_relative_entrypoint(const char *entrypoint) { return true; } +static bool is_valid_module_name(const char *name) { + size_t len; + + if (!name || name[0] == '\0') { + return false; + } + + len = strlen(name); + if (len > TNT_MODULE_NAME_MAX || + len >= sizeof(((tnt_module_manifest_t *)0)->name) || + name[0] == '-' || name[len - 1] == '-') { + return false; + } + + for (const unsigned char *p = (const unsigned char *)name; *p; p++) { + if ((*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + *p == '-') { + continue; + } + return false; + } + + return true; +} + static bool json_array_contains_string(const char *json, const char *key, const char *value) { char needle[128]; @@ -144,7 +178,7 @@ int tnt_module_manifest_load(const char *module_dir, sizeof(out->name)) || !tnt_json_get_string_field(manifest, "entrypoint", out->entrypoint, sizeof(out->entrypoint)) || - !is_valid_username(out->name) || + !is_valid_module_name(out->name) || !is_safe_relative_entrypoint(out->entrypoint)) { return -1; } @@ -351,8 +385,8 @@ static void publish_module_message(const module_process_t *module, if (!module || !plain_text || plain_text[0] == '\0') return; - snprintf(msg.username, sizeof(msg.username), "module:%s", - module->manifest.name); + snprintf(msg.username, sizeof(msg.username), "module:%.*s", + TNT_MODULE_NAME_MAX, module->manifest.name); snprintf(msg.content, sizeof(msg.content), "%s", plain_text); if (message_save(&msg) < 0) { @@ -364,23 +398,27 @@ static void publish_module_message(const module_process_t *module, notify_mentions(msg.content, NULL); } -static void handle_module_response(module_process_t *module, const char *line) { +static module_response_action_t 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 (!module || !line || line[0] == '\0') { + return MODULE_RESPONSE_INVALID; + } if (tnt_module_parse_message_create(line, &create)) { publish_module_message(module, create.plain_text); - return; + return MODULE_RESPONSE_CONTINUE; } if (tnt_json_get_string_field(line, "type", type, sizeof(type)) && strcmp(type, "event.ok") == 0) { - return; + return MODULE_RESPONSE_DONE; } fprintf(stderr, "module runtime: ignored invalid response from %s\n", module->manifest.name); + return MODULE_RESPONSE_INVALID; } static void deliver_message_to_module(module_process_t *module, @@ -390,6 +428,7 @@ static void deliver_message_to_module(module_process_t *module, char line[TNT_MODULE_LINE_MAX]; char message_id[64]; size_t pos = 0; + int responses = 0; if (!module || !module->active || !msg) return; @@ -417,7 +456,31 @@ static void deliver_message_to_module(module_process_t *module, close_module_process(module); return; } - handle_module_response(module, line); + responses++; + if (responses > TNT_MODULE_MAX_RESPONSES_PER_EVENT) { + fprintf(stderr, + "module runtime: disabling %s after too many responses\n", + module->manifest.name); + close_module_process(module); + return; + } + + module_response_action_t action = handle_module_response(module, line); + if (action == MODULE_RESPONSE_DONE) { + module->invalid_responses = 0; + return; + } + if (action == MODULE_RESPONSE_INVALID) { + module->invalid_responses++; + if (module->invalid_responses >= TNT_MODULE_MAX_INVALID_RESPONSES) { + fprintf(stderr, + "module runtime: disabling %s after invalid responses\n", + module->manifest.name); + close_module_process(module); + } + return; + } + module->invalid_responses = 0; } } diff --git a/tests/test_check_maintainers.sh b/tests/test_check_maintainers.sh new file mode 100755 index 0000000..425a198 --- /dev/null +++ b/tests/test_check_maintainers.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Manual regression tests for scripts/check_maintainers.sh. + +set -u + +PASS=0 +FAIL=0 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +pass() { + echo "PASS $1" + PASS=$((PASS + 1)) +} + +fail_case() { + echo "FAIL $1" + FAIL=$((FAIL + 1)) +} + +echo "=== TNT Maintainer Coverage Tests ===" + +if "$ROOT/scripts/check_maintainers.sh" src/module_runtime.c docs/MODULE_PROTOCOL.md >/dev/null; then + pass "known paths are covered" +else + fail_case "known paths are covered" +fi + +if "$ROOT/scripts/check_maintainers.sh" no/such/path >/dev/null 2>&1; then + fail_case "unknown path is rejected" +else + pass "unknown path is rejected" +fi + +if output=$("$ROOT/scripts/check_maintainers.sh" 2>&1); then + pass "repository file set is covered" +else + printf '%s\n' "$output" + fail_case "repository file set is covered" +fi + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/tnt-maintainers.XXXXXX") || exit 1 +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT INT TERM + +mkdir -p "$tmp/include" "$tmp/src" "$tmp/scripts" "$tmp/obj" "$tmp/tests/unit" +cp "$ROOT/MAINTAINERS" "$tmp/MAINTAINERS" +cp "$ROOT/scripts/check_maintainers.sh" "$tmp/scripts/check_maintainers.sh" +cp "$ROOT/scripts/get_maintainer.sh" "$tmp/scripts/get_maintainer.sh" +chmod +x "$tmp/scripts/check_maintainers.sh" "$tmp/scripts/get_maintainer.sh" +: > "$tmp/include/common.h" +: > "$tmp/src/main.c" +: > "$tmp/tests/unit/test_utf8.c" +: > "$tmp/obj/main.o" +: > "$tmp/tnt" +: > "$tmp/tntctl" +: > "$tmp/tests/unit/test_utf8" +: > "$tmp/tests/unit/test_utf8.o" +: > "$tmp/tests/unit/test_messages.log" +chmod +x "$tmp/tests/unit/test_utf8" + +if (cd "$tmp" && scripts/check_maintainers.sh >/dev/null); then + pass "source archive fallback ignores generated files" +else + fail_case "source archive fallback ignores generated files" +fi + +printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_get_maintainer.sh b/tests/test_get_maintainer.sh new file mode 100755 index 0000000..5564512 --- /dev/null +++ b/tests/test_get_maintainer.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Manual regression tests for scripts/get_maintainer.sh. + +set -u + +PASS=0 +FAIL=0 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +pass() { + echo "PASS $1" + PASS=$((PASS + 1)) +} + +fail_case() { + echo "FAIL $1" + FAIL=$((FAIL + 1)) +} + +echo "=== TNT Maintainer Map Tests ===" + +MODULE_OUTPUT=$("$ROOT/scripts/get_maintainer.sh" src/module_runtime.c) +if printf '%s\n' "$MODULE_OUTPUT" | + awk -F'\t' '$2 == "MODULE CORE INTERFACE" && $4 == "src/module_*.c" { found = 1 } END { exit found ? 0 : 1 }'; then + pass "module runtime maps to module core interface" +else + fail_case "module runtime maps to module core interface" + printf '%s\n' "$MODULE_OUTPUT" +fi + +DOC_OUTPUT=$("$ROOT/scripts/get_maintainer.sh" docs/MODULE_PROTOCOL.md) +if printf '%s\n' "$DOC_OUTPUT" | + awk -F'\t' '$2 == "MODULE CORE INTERFACE" { module = 1 } $2 == "DOCUMENTATION" { docs = 1 } END { exit module && docs ? 0 : 1 }'; then + pass "module protocol maps to module and documentation areas" +else + fail_case "module protocol maps to module and documentation areas" + printf '%s\n' "$DOC_OUTPUT" +fi + +UNKNOWN_OUTPUT=$("$ROOT/scripts/get_maintainer.sh" no/such/path) +if printf '%s\n' "$UNKNOWN_OUTPUT" | + awk -F'\t' '$2 == "UNKNOWN" && $3 == "unknown" { found = 1 } END { exit found ? 0 : 1 }'; then + pass "unknown paths report unknown" +else + fail_case "unknown paths report unknown" + printf '%s\n' "$UNKNOWN_OUTPUT" +fi + +if "$ROOT/scripts/get_maintainer.sh" >/dev/null 2>&1; then + fail_case "missing path exits nonzero" +else + pass "missing path exits nonzero" +fi + +printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_install_wizard.sh b/tests/test_install_wizard.sh new file mode 100755 index 0000000..4a1dc75 --- /dev/null +++ b/tests/test_install_wizard.sh @@ -0,0 +1,142 @@ +#!/bin/sh +# Manual regression tests for scripts/install_wizard.sh. + +set -u + +PASS=0 +FAIL=0 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-install-wizard-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "PASS $1" + PASS=$((PASS + 1)) +} + +fail_case() { + echo "FAIL $1" + FAIL=$((FAIL + 1)) +} + +write_module() { + dir=$1 + name=$2 + min_version=${3:-} + mkdir -p "$dir" + min_line= + [ -z "$min_version" ] || min_line=" \"tnt_min_version\": \"$min_version\"," + cat >"$dir/tnt-module.json" <"$dir/module.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + case "$line" in + *'"type":"handshake"'*|*'"type": "handshake"'*) + printf '%s\n' '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"test","version":"0.1.0"}}' + ;; + *) + printf '%s\n' '{"type":"event.ok"}' + ;; + esac +done +SH + chmod +x "$dir/module.sh" +} + +echo "=== TNT Install Wizard Tests ===" + +out="$STATE_DIR/core.env" +TNT_SETUP_PROFILE=core \ +TNT_SETUP_PORT=3333 \ +TNT_SETUP_BIND_ADDR=127.0.0.1 \ +TNT_SETUP_STATE_DIR=/tmp/tnt-state \ +TNT_SETUP_MAX_CONNECTIONS=12 \ + "$ROOT/scripts/install_wizard.sh" --non-interactive --output "$out" >/dev/null 2>&1 +if grep -q "^PORT='3333'$" "$out" && + grep -q "^TNT_BIND_ADDR='127.0.0.1'$" "$out" && + ! grep -q '^TNT_MODULE_PATHS=' "$out"; then + pass "core profile writes env without modules" +else + fail_case "core profile writes env without modules" + cat "$out" +fi + +module_root="$STATE_DIR/modules" +write_module "$module_root/echo-module" "echo-module" +write_module "$module_root/bad-module" "Bad_Module" +write_module "$module_root/future-module" "future-module" "9.0.0" + +MODULES_OUTPUT=$("$ROOT/scripts/install_wizard.sh" --print-modules \ + --module-root "$module_root") +if printf '%s\n' "$MODULES_OUTPUT" | + awk -F'\t' -v path="$module_root/echo-module" \ + '$2 == "ok" && $3 == "echo-module" && $4 == path { found = 1 } END { exit found ? 0 : 1 }' && + printf '%s\n' "$MODULES_OUTPUT" | + awk -F'\t' -v path="$module_root/bad-module" \ + '$2 == "invalid" && $3 == "Bad_Module" && $4 == path { found = 1 } END { exit found ? 0 : 1 }' && + printf '%s\n' "$MODULES_OUTPUT" | + awk -F'\t' -v path="$module_root/future-module" \ + '$2 == "invalid" && $3 == "future-module" && $4 == path { found = 1 } END { exit found ? 0 : 1 }'; then + pass "module scan prints compatibility status" +else + fail_case "module scan prints compatibility status" + printf '%s\n' "$MODULES_OUTPUT" +fi + +out="$STATE_DIR/all.env" +TNT_SETUP_PROFILE=all \ +TNT_SETUP_MODULE_ROOT="$module_root" \ + "$ROOT/scripts/install_wizard.sh" --non-interactive --output "$out" >/dev/null 2>&1 +if grep -q "^TNT_MODULE_PATHS='$module_root/echo-module'$" "$out" && + ! grep -q 'bad-module' "$out" && + ! grep -q 'future-module' "$out"; then + pass "all profile enables only compatible valid modules" +else + fail_case "all profile enables only compatible valid modules" + cat "$out" +fi + +out="$STATE_DIR/manual.env" +TNT_SETUP_PROFILE=manual \ +TNT_SETUP_MODULE_PATHS="$module_root/echo-module" \ + "$ROOT/scripts/install_wizard.sh" --non-interactive --output "$out" >/dev/null 2>&1 +if grep -q "^TNT_MODULE_PATHS='$module_root/echo-module'$" "$out"; then + pass "manual profile validates explicit module paths" +else + fail_case "manual profile validates explicit module paths" + cat "$out" +fi + +out="$STATE_DIR/select.env" +if TNT_SETUP_PROFILE=select \ + TNT_SETUP_MODULE_ROOT="$module_root" \ + TNT_SETUP_MODULE_SELECTION=1 \ + "$ROOT/scripts/install_wizard.sh" --non-interactive --output "$out" >/dev/null 2>&1; then + fail_case "select profile rejects invalid selected modules" +else + pass "select profile rejects invalid selected modules" +fi + +if TNT_SETUP_PROFILE=core TNT_SETUP_PORT=70000 \ + "$ROOT/scripts/install_wizard.sh" --non-interactive --output "$STATE_DIR/bad.env" >/dev/null 2>&1; then + fail_case "invalid port is rejected" +else + pass "invalid port is rejected" +fi + +printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_module_check.sh b/tests/test_module_check.sh new file mode 100755 index 0000000..5b2f4bf --- /dev/null +++ b/tests/test_module_check.sh @@ -0,0 +1,151 @@ +#!/bin/sh +# Regression tests for scripts/module_check.sh. + +set -eu + +PASS=0 +FAIL=0 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-module-check-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "PASS $1" + PASS=$((PASS + 1)) +} + +fail_case() { + echo "FAIL $1" + FAIL=$((FAIL + 1)) +} + +write_module() { + dir=$1 + name=$2 + min_version=${3:-} + mkdir -p "$dir" + min_line= + [ -z "$min_version" ] || min_line=" \"tnt_min_version\": \"$min_version\"," + cat >"$dir/tnt-module.json" <"$dir/module.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + case "$line" in + *'"type":"handshake"'*|*'"type": "handshake"'*) + printf '%s\n' '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"test","version":"0.1.0"}}' + ;; + *) + printf '%s\n' '{"type":"event.ok"}' + ;; + esac +done +SH + chmod +x "$dir/module.sh" +} + +echo "=== TNT Module Check Tests ===" + +valid_dir="$STATE_DIR/valid" +write_module "$valid_dir" "echo-module" +if "$ROOT/scripts/module_check.sh" "$valid_dir" >/dev/null; then + pass "valid module passes" +else + fail_case "valid module passes" +fi + +plain_entry_dir="$STATE_DIR/plain-entry" +write_module "$plain_entry_dir" "plain-entry" +sed 's#"entrypoint": "./module.sh"#"entrypoint": "module.sh"#' \ + "$plain_entry_dir/tnt-module.json" >"$plain_entry_dir/tnt-module.json.tmp" +mv "$plain_entry_dir/tnt-module.json.tmp" "$plain_entry_dir/tnt-module.json" +if "$ROOT/scripts/module_check.sh" "$plain_entry_dir" >/dev/null; then + pass "relative entrypoint without slash passes" +else + fail_case "relative entrypoint without slash passes" +fi + +compatible_dir="$STATE_DIR/compatible" +write_module "$compatible_dir" "compatible-module" "1.0.1" +if "$ROOT/scripts/module_check.sh" --tnt-version 1.0.1 "$compatible_dir" >/dev/null; then + pass "compatible TNT minimum version passes" +else + fail_case "compatible TNT minimum version passes" +fi + +future_dir="$STATE_DIR/future" +write_module "$future_dir" "future-module" "9.0.0" +if "$ROOT/scripts/module_check.sh" --tnt-version 1.0.1 "$future_dir" >/dev/null 2>&1; then + fail_case "future TNT minimum version is rejected" +else + pass "future TNT minimum version is rejected" +fi + +bad_name_dir="$STATE_DIR/bad-name" +write_module "$bad_name_dir" "Echo_Module" +if "$ROOT/scripts/module_check.sh" "$bad_name_dir" >/dev/null 2>&1; then + fail_case "invalid module name is rejected" +else + pass "invalid module name is rejected" +fi + +long_name_dir="$STATE_DIR/long-name" +write_module "$long_name_dir" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +if "$ROOT/scripts/module_check.sh" "$long_name_dir" >/dev/null 2>&1; then + fail_case "overlong module name is rejected" +else + pass "overlong module name is rejected" +fi + +bad_protocol_dir="$STATE_DIR/bad-protocol" +write_module "$bad_protocol_dir" "bad-protocol" +sed 's/tnt.module.v1/tnt.module.v2/' "$bad_protocol_dir/tnt-module.json" \ + >"$bad_protocol_dir/tnt-module.json.tmp" +mv "$bad_protocol_dir/tnt-module.json.tmp" "$bad_protocol_dir/tnt-module.json" +if "$ROOT/scripts/module_check.sh" "$bad_protocol_dir" >/dev/null 2>&1; then + fail_case "wrong protocol is rejected" +else + pass "wrong protocol is rejected" +fi + +bad_handshake_dir="$STATE_DIR/bad-handshake" +write_module "$bad_handshake_dir" "bad-handshake" +cat >"$bad_handshake_dir/module.sh" <<'SH' +#!/bin/sh +printf '%s\n' '{"type":"event.ok"}' +SH +chmod +x "$bad_handshake_dir/module.sh" +if "$ROOT/scripts/module_check.sh" "$bad_handshake_dir" >/dev/null 2>&1; then + fail_case "bad handshake is rejected" +else + pass "bad handshake is rejected" +fi + +silent_dir="$STATE_DIR/silent" +write_module "$silent_dir" "silent-module" +cat >"$silent_dir/module.sh" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$silent_dir/module.sh" +if "$ROOT/scripts/module_check.sh" "$silent_dir" >/dev/null 2>&1; then + fail_case "silent module is rejected" +else + pass "silent module is rejected" +fi + +printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_module_runtime.sh b/tests/test_module_runtime.sh index bb2455f..6873382 100755 --- a/tests/test_module_runtime.sh +++ b/tests/test_module_runtime.sh @@ -7,13 +7,20 @@ FAIL=0 BIN="../tnt" STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-module-test.XXXXXX") MODULE_DIR="$STATE_DIR/echo-module" +FLOOD_MODULE_DIR="$STATE_DIR/flood-module" +INVALID_MODULE_DIR="$STATE_DIR/invalid-module" SERVER_PID="" -cleanup() { +stop_server() { if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" fi +} + +cleanup() { + stop_server rm -rf "$STATE_DIR" } @@ -70,6 +77,68 @@ done SH chmod +x "$MODULE_DIR/echo-module.sh" +mkdir -p "$FLOOD_MODULE_DIR" +cat >"$FLOOD_MODULE_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "flood-module", + "version": "0.1.0", + "entrypoint": "./flood-module.sh", + "permissions": ["message:read", "message:create"], + "events": ["message.created"] +} +JSON + +cat >"$FLOOD_MODULE_DIR/flood-module.sh" <<'SH' +#!/bin/sh +extract_string() { + key=$1 + line=$2 + printf '%s\n' "$line" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" +} +while IFS= read -r line; do + plain_text=$(extract_string plain_text "$line") + if printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"handshake"'; then + printf '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"flood-module","version":"0.1.0"}}\n' + elif printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"message.created"'; then + i=1 + while [ "$i" -le 9 ]; do + printf '{"type":"message.create","plain_text":"flood %s: %s"}\n' "$i" "$plain_text" + i=$((i + 1)) + done + else + printf '{"type":"event.ok"}\n' + fi +done +SH +chmod +x "$FLOOD_MODULE_DIR/flood-module.sh" + +mkdir -p "$INVALID_MODULE_DIR" +cat >"$INVALID_MODULE_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "invalid-module", + "version": "0.1.0", + "entrypoint": "./invalid-module.sh", + "permissions": ["message:read", "message:create"], + "events": ["message.created"] +} +JSON + +cat >"$INVALID_MODULE_DIR/invalid-module.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + if printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"handshake"'; then + printf '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"invalid-module","version":"0.1.0"}}\n' + elif printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"message.created"'; then + printf '{"type":"not.allowed"}\n' + else + printf '{"type":"event.ok"}\n' + fi +done +SH +chmod +x "$INVALID_MODULE_DIR/invalid-module.sh" + echo "=== TNT Module Runtime Tests ===" TNT_LANG=en TNT_RATE_LIMIT=0 TNT_MODULE_PATHS="$MODULE_DIR" \ @@ -126,5 +195,153 @@ else FAIL=$((FAIL + 1)) fi +stop_server + +TNT_LANG=en TNT_RATE_LIMIT=0 TNT_MODULE_PATHS="$FLOOD_MODULE_DIR" \ + "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/flood-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 flood server failed to start" + sed -n '1,160p' "$STATE_DIR/flood-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 flood module" + PASS=$((PASS + 1)) +else + echo "x flood health failed: $HEALTH_OUTPUT" + sed -n '1,160p' "$STATE_DIR/flood-server.log" + FAIL=$((FAIL + 1)) +fi + +POST_OUTPUT=$(ssh $SSH_OPTS alice@localhost post "trigger flood" 2>/dev/null || true) +if [ "$POST_OUTPUT" = "posted" ]; then + echo "✓ flood trigger post succeeds" + PASS=$((PASS + 1)) +else + echo "x flood trigger post failed: $POST_OUTPUT" + FAIL=$((FAIL + 1)) +fi + +DISABLED=0 +for _ in 1 2 3 4 5; do + if grep -q 'too many responses' "$STATE_DIR/flood-server.log"; then + DISABLED=1 + break + fi + sleep 1 +done + +if [ "$DISABLED" -eq 1 ]; then + echo "✓ flood module is disabled after too many responses" + PASS=$((PASS + 1)) +else + echo "x flood module was not disabled" + sed -n '1,200p' "$STATE_DIR/flood-server.log" + FAIL=$((FAIL + 1)) +fi + +POST_OUTPUT=$(ssh $SSH_OPTS bob@localhost post "after disable" 2>/dev/null || true) +sleep 1 +TAIL_OUTPUT=$(ssh $SSH_OPTS localhost "tail -n 20" 2>/dev/null || true) +HEALTH_OUTPUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) +if [ "$POST_OUTPUT" = "posted" ] && + [ "$HEALTH_OUTPUT" = "ok" ] && + ! printf '%s\n' "$TAIL_OUTPUT" | grep -q 'module:flood-module.*after disable'; then + echo "✓ disabled flood module stays isolated while server remains healthy" + PASS=$((PASS + 1)) +else + echo "x disabled flood module isolation failed" + printf '%s\n' "$TAIL_OUTPUT" + sed -n '1,240p' "$STATE_DIR/flood-server.log" + FAIL=$((FAIL + 1)) +fi + +stop_server + +TNT_LANG=en TNT_RATE_LIMIT=0 TNT_MODULE_PATHS="$INVALID_MODULE_DIR" \ + "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/invalid-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 invalid-response server failed to start" + sed -n '1,160p' "$STATE_DIR/invalid-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 invalid-response module" + PASS=$((PASS + 1)) +else + echo "x invalid-response health failed: $HEALTH_OUTPUT" + sed -n '1,160p' "$STATE_DIR/invalid-server.log" + FAIL=$((FAIL + 1)) +fi + +INVALID_POSTS_OK=1 +for message in invalid-one invalid-two invalid-three; do + POST_OUTPUT=$(ssh $SSH_OPTS carol@localhost post "$message" 2>/dev/null || true) + [ "$POST_OUTPUT" = "posted" ] || INVALID_POSTS_OK=0 +done + +if [ "$INVALID_POSTS_OK" -eq 1 ]; then + echo "✓ invalid-response trigger posts succeed" + PASS=$((PASS + 1)) +else + echo "x invalid-response trigger post failed" + sed -n '1,200p' "$STATE_DIR/invalid-server.log" + FAIL=$((FAIL + 1)) +fi + +DISABLED=0 +for _ in 1 2 3 4 5; do + if grep -q 'invalid-module after invalid responses' "$STATE_DIR/invalid-server.log"; then + DISABLED=1 + break + fi + sleep 1 +done + +if [ "$DISABLED" -eq 1 ]; then + echo "✓ invalid-response module is disabled after repeated errors" + PASS=$((PASS + 1)) +else + echo "x invalid-response module was not disabled" + sed -n '1,240p' "$STATE_DIR/invalid-server.log" + FAIL=$((FAIL + 1)) +fi + +INVALID_COUNT=$(grep -c 'ignored invalid response from invalid-module' \ + "$STATE_DIR/invalid-server.log" || true) +POST_OUTPUT=$(ssh $SSH_OPTS dave@localhost post "after invalid disable" 2>/dev/null || true) +sleep 1 +INVALID_COUNT_AFTER=$(grep -c 'ignored invalid response from invalid-module' \ + "$STATE_DIR/invalid-server.log" || true) +HEALTH_OUTPUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) +if [ "$POST_OUTPUT" = "posted" ] && + [ "$HEALTH_OUTPUT" = "ok" ] && + [ "$INVALID_COUNT_AFTER" = "$INVALID_COUNT" ]; then + echo "✓ disabled invalid-response module stays isolated while server remains healthy" + PASS=$((PASS + 1)) +else + echo "x disabled invalid-response module isolation failed" + sed -n '1,260p' "$STATE_DIR/invalid-server.log" + FAIL=$((FAIL + 1)) +fi + printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c index 6ce93b8..d06c673 100644 --- a/tests/unit/test_module_runtime.c +++ b/tests/unit/test_module_runtime.c @@ -142,6 +142,54 @@ TEST(rejects_unsafe_entrypoint) { cleanup_module_dir(); } +TEST(rejects_invalid_module_names) { + tnt_module_manifest_t manifest; + char long_name[TNT_MODULE_NAME_MAX + 2]; + char body[512]; + + 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_module\"," + "\"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\":\"./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\":\"./echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]}"); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + + memset(long_name, 'a', sizeof(long_name) - 1); + long_name[sizeof(long_name) - 1] = '\0'; + snprintf(body, sizeof(body), + "{\"protocol\":\"tnt.module.v1\",\"name\":\"%s\"," + "\"entrypoint\":\"./echo.sh\"," + "\"permissions\":[\"message:read\",\"message:create\"]," + "\"events\":[\"message.created\"]}", + long_name); + write_manifest(body); + assert(tnt_module_manifest_load(module_dir, &manifest) < 0); + + cleanup_module_dir(); +} + int main(void) { printf("Running module runtime unit tests...\n\n"); @@ -149,6 +197,7 @@ int main(void) { RUN_TEST(rejects_wrong_protocol); RUN_TEST(rejects_missing_permissions_or_events); RUN_TEST(rejects_unsafe_entrypoint); + RUN_TEST(rejects_invalid_module_names); printf("\nAll %d module runtime tests passed.\n", tests_passed); return 0; diff --git a/tnt.1 b/tnt.1 index 2eb5fa5..3064516 100644 --- a/tnt.1 +++ b/tnt.1 @@ -1,5 +1,5 @@ .\" tnt(1) - Terminal Network Talk -.TH TNT 1 "2026-05-24" "TNT 1.0.1" "User Commands" +.TH TNT 1 "2026-06-16" "TNT 1.1.0" "User Commands" .SH NAME tnt \- anonymous SSH chat server with Vim\-style TUI .SH SYNOPSIS diff --git a/tntctl.1 b/tntctl.1 index dc02696..b33e8ea 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -1,5 +1,5 @@ .\" tntctl(1) - TNT control client -.TH TNTCTL 1 "2026-05-24" "TNT 1.0.1" "User Commands" +.TH TNTCTL 1 "2026-06-16" "TNT 1.1.0" "User Commands" .SH NAME tntctl \- thin control client for a TNT server .SH SYNOPSIS